DEV Community

Cover image for Simplified Redux
Sultan
Sultan

Posted on • Updated on

Simplified Redux

In the previous article, Declarative JavaScript, we discussed how conditional flows can be represented as higher-order functions. In this chapter, we will continue to extend this concept to create Redux reducers. Despite its declining popularity, Redux can serve as a good example of how to reduce the boilerplate code around this library. Before proceeding, I highly recommend reading the first chapter.

Abstractions

You may have noticed that whenever you point at some object, if there is a dog alongside you, it will most likely just stare at your fingertip rather than the object itself. Dogs struggle to understand the abstract line between the finger and the intended object, because such a concept simply does not exist in their cognition. Fortunately, humans have this ability, and it allows us to simplify complex things and present them in a simple form.

In the diagram above, a blue square symbolizes an application, and a barrel attached to the box represents a database. Such diagrams help us understand complex architectural solutions without diving into the details of their implementation. The same method can be applied in programming by separating the main code from the secondary code by hiding them behind functions.

const initialState = {
  attempts: 0,
  isSigned: false,
}

// action payload πŸ‘‡      πŸ‘‡ current state
const signIn = pincode => state => ({
  attempts: state.attempts + 1,
  isSigned: pincode === '2023',
})

const signOut = () => () => initialState

const authReducer = createReducer(
  initialState,
  on('SIGN_IN', signIn),
  on('SIGN_OUT', signOut),
  on('SIGN_OUT', clearCookies),
)
Enter fullscreen mode Exit fullscreen mode

The input of the createReducer function is like the table of contents for a book. It allows us to quickly finding the necessary function based on the action type. Both signIn and signOut functions update the state object, accepting the action payload and the current state as input. The rest of the code, which involves the action type check and the reducer call, is encapsulated within the createReducer and on functions.

const createReducer = (initialState, ...fns) => (state, action) => (
  fns.reduce(
    (nextState, fn) => fn(nextState, action),
    state || initialState,
  )
)

const on = (actionType, reducer) => (state, action) => (
  action.type === actionType
    ? reducer(action.payload)(state)
    : state
)
Enter fullscreen mode Exit fullscreen mode

For enhanced utility, we introduce the helper functions useAction and useStore:

import {useState} from 'react'
import {useDispatch, useSelector} from 'react-redux'

const useAction = type => {
  const dispatch = useDispatch()
  return payload => dispatch({type, payload})
}

const useStore = path => (
  useSelector(state => state[path])
)

const SignIn = () => {
  const [pincode, setPincode] = useState('')
  const signIn = useAction('SIGN_IN')
  const attempts = useStore('attempts')

  return (
    <form>
      <h1>You have {3 - attempts} attempts!</h1>
      <input
        value={pincode}
        onChange={event => setPin(event.target.value)}
      />
      <button
        disabled={attempts >= 3}
        onClick={() => signIn(pincode)}>
        Sign In
      </button>
    </form>
  )
}
Enter fullscreen mode Exit fullscreen mode

Lenses

In functional programming, lenses are abstractions that make operations easier with nested data structures, such as objects or arrays. In other words, they are immutable setters and getters. They are called lenses because they allow us to focus on a specific part of an object.

lens

First, let's see how to update a value without using lenses:

const initialState = {
  lastUpdate: new Date(),
  user: {
    firstname: 'Peter',
    lastname: 'Griffin',
    phoneNumbers: ['+19738720421'],
    address: {
      street: '31 Spooner',
      zip: '00093',
      city: 'Quahog',
      state: 'Rhode Island',
    }
  }
}

const updateCity = name => state => ({
  ...state,
  user: {
    ...state.user,
    address: {
      ...state.user.address,
      city: name,
    }
  }
})

const userReducer = createReducer(
  initialState,
  on('UPDATE_CITY', updateCity),
  ...
)
Enter fullscreen mode Exit fullscreen mode

That’s pretty ugly, right? Now let's use the set/get lenses:

const updateCity = name => state => (
  set('user.address.city', name, state)
)

const updateHomePhone = value => state => (
  set('user.phoneNumbers.0', value, state)
)

const useStore = path => useSelector(get(path))
// or by composing
const useStore = compose(useSelector, get)

const homePhone = useStore('user.phoneNumbers.0')
Enter fullscreen mode Exit fullscreen mode

Below is an implementation of the lenses. I should note that all of these functions are already available in the Ramda library.

const update = (keys, fn, obj) => {
  const [key, ...rest] = keys
  if (keys.length === 1) {
    return Array.isArray(obj)
      ? obj.map((v, i) => i.toString() === key ? fn(v) : v)
      : {...obj, [key]: fn(obj[key])}
  }

  return Array.isArray(obj)
    ? obj.map((v, i) => i.toString() === key ? update(rest, fn, v) : v)
    : {...obj, [key]: update(rest, fn, obj[key])}
}

const get = value => obj => (
  value
    .split(`.`)
    .reduce((acc, key) => acc?.[key], obj)
)

const set = (path, fn, object) => (
  update(
    path.split('.'),
    typeof fn === 'function' ? fn : () => fn,
    object,
  )
)

const compose = (...fns) => (...args) => (
  fns.reduceRight(
    (x, fn, index) => index === fns.length - 1 ? fn(...x) : fn(x),
    args
  )
)
Enter fullscreen mode Exit fullscreen mode

Curring functions

A curried function can be referred to as a function factory. We can assemble a function from different places, or we can use them in composition with other functions. This is a truly powerful feature. However, when we call a curried function with all parameters at once, it may look clumsy. For instance, the call of the curried version of set would look like this:

set('user.address.city')(name)(state)
Enter fullscreen mode Exit fullscreen mode

Let's introduce a curry function. This function converts any given function into a curried one, and it can be invoked in various ways:

set('user.address.city', 'Harrisburg', state) // f(x, y, z)
set('user.address.city', 'Harrisburg')(state) // f(x, y)(z)
set('user.address.city')('Harrisburg', state) // f(x)(y, z)
set('user.address.city')('Harrisburg')(state) // f(x)(y)(z)
Enter fullscreen mode Exit fullscreen mode

Quite simple implementation and usage:

const curry = fn => (...args) => (
  args.length >= fn.length
    ? fn(...args)
    : curry(fn.bind(undefined, ...args))
)

const set = curry((path, fn, object) =>
  update(
    path.split('.'),
    typeof fn === 'function' ? fn : () => fn,
    object,
  )
)

const get = curry((value, obj) =>
  value
    .split(`.`)
    .reduce((acc, key) => acc?.[key], obj)
)
Enter fullscreen mode Exit fullscreen mode

I often notice that many developers, for whatever reason, wrap callback functions in anonymous functions just to pass a parameter.

fetch('api/users')
  .then(res => res.json())
  .then(data => setUsers(data)) // πŸ‘ˆ no need to wrap
  .catch(error => console.log(error))   // πŸ‘ˆ no need to wrap
Enter fullscreen mode Exit fullscreen mode

Instead, we can pass the function as a parameter. We should remember that the then function expects a callback function with two parameters. Therefore, direct passing will be safe only if setUsers expects only one parameter.

fetch('api/users')
  .then(res => res.json())
  .then(setUsers)
  .catch(console.log)
Enter fullscreen mode Exit fullscreen mode

This reminds me of simplifying fractions or equations in basic algebra.

(2xβˆ’1)(x+2)=3(x+2)(2xβˆ’1)(x+2)=3(x+2)2xβˆ’1=3 (2x - 1)(x + 2) = 3(x + 2) \newline (2x - 1)\cancel{(x + 2)} = 3\cancel{(x + 2)} \newline 2x - 1 = 3
.then(data => setUsers(data))
// πŸ‘‡ it equals πŸ‘†
.then(setUsers)
Enter fullscreen mode Exit fullscreen mode

Let's simplify the updateCity function:

const updateCity = name => state => (
  set('user.address.city', name, state)
)
// πŸ‘‡ it equals πŸ‘†
const updateCity = set('user.address.city')
Enter fullscreen mode Exit fullscreen mode

Or, we can place it directly in the reducer without declaring a variable.

const userReducer = createReducer(
  initialState,
  on('UPDATE_CITY', set('user.address.city')),
  on('UPDATE_HOME_PHONE', set('user.phones.0')),
  ...
)
Enter fullscreen mode Exit fullscreen mode

The most important aspect is that we can now compose the set function and perform multiple updates at once.

const signIn = pincode => state => ({
  attempts: state.attempts + 1
  isSigned: pincode === '2023',
})

// 'state => ({ ...' is πŸ‘‡ removed
const signIn = pincode => compose(
  set('attempts', attempts => attempts + 1),
  set('isSigned', pincode === '2023'),
)

const updateCity = name => state => ({
  lastUpdate: new Date(),
  user: {
    ...state.user,
    address: {
      ...state.user.address,
      city: name,
    }
  }
})

// πŸ‘† it equals πŸ‘‡
const updateCity = name => compose(
  set('lastUpdate', new Date()),
  set('user.address.city', name),
)
Enter fullscreen mode Exit fullscreen mode

This programming style is called point free and is widely used in functional programming.

fp in action

This article ended up being a bit longer than I had initially planned, but I hope you’ve gained some new knowledge. In the next article, we will touch on the topic of the try/catch construct and as usual, here's a small teaser for the next post:

const result = trap(unstableCode)
  .pass(x => x * 2)
  .catch(() => 3)
  .release(x => x * 10)
Enter fullscreen mode Exit fullscreen mode

P.S.
You can check out the online demo here πŸ“Ί.

For a demo with TypeScript, visit this online demo πŸ•.

Top comments (0)