Next Level Reducers

Trevor Wright
Prodigy Engineering
4 min readNov 18, 2021
Photo by Benjamin Wedemeyer on Unsplash

I suspect everyone who has spent some time in React has experienced working with reducers either through useReducer or with tooling such as Redux. If you have not had a lot of experience with reducers, they boil down to a pure function which is given the current state and an action describing how to modify the state and will return the new state with those changes applied.

Reducers are a fantastic tool to reach for when:

  1. You have portions of your state which can depend on other elements of your state
  2. You want to encapsulate logic about how state should be updated based on specific events happening

The following is an example of a reducer hook in a React component which we’ll continue to work with throughout. It is overly simple for demonstration purposes and something you could just implement with React.useState. We have a counter component where a user can either increase or decrease the count by a specified amount. For simplicity, we’ll just hard code the increment amount, but it could easily be a value coming from a user input.

Now that we have a starting point for how reducers tend to look out of the box, let’s take a look to see how we can make them even better!

No more switch

I don't know about you, but I find reading, writing, and maintaining switch cases really annoying in reducers. Instead of switch cases what if we could work with our good friend key-value pairs. Let's create a new reducer hook called useReducer . It will function in the same way as React.useReducer except that it uses a key-value pair as the reducer instead of a switch case. The key-value pair will be:

{
[action.type]: (state, action.payload) => {
// reducer logic for action.type
}
}

We achieve this by creating a function that uses the action.type to invoke the corresponding reducer function stored in the actionMap with the current state and action payload. We can then use this reducer mapping function as the reducer for the standard React.useReducer hook.

Now we have a nice little utility we can use everywhere and it makes our component a lot easier to read and understand.

Immer

Now that the switch cases are gone, I can’t help but notice the spread operators ( syntax) that end up all over the place to ensure we treat the state object as immutable. The problem is that we think about the changes we want to make in a mutable way, but technical requirements lead us to write our code in an immutable way.

Enter Immer, a fantastic library for handling immutable data structures which leverages JavaScript’s Proxy. If you are unfamiliar with the details and are interested I highly recommend reading the intro blog post but it is not required knowledge for this post. Immer allows us to modify a data structure directly as if it was mutable and it will take care of the work to ensure that it stays immutable.

The Immer package exposes a default function that does all the work.

produce(currentState, recipe: (draftState) => void): nextState

produce takes a base state, and a recipe that can be used to perform all the desired mutations on the draft that is passed in. The interesting thing about Immer is that the baseState will be untouched, but the nextState will reflect all changes made to draftState.

Let’s extend our useReducer utility we wrote to integrate with our newfound library Immer. We will wrap our map reducer with the produce function provided by Immer which will provide our reducer functions with a draft object instead of the actual state which can be mutated directly. We then can update our increment and decrement functions to work with Immer.

When working with Immer you do not need to return the new state, you just modify it directly.

Abstraction pattern

Well this is all starting to look a lot better, but there is one more thing I think we can improve. Calling dispatch directly all over our code is a bit verbose and can be annoying to refactor at times. Instead, let’s create an abstraction that allows for clean function calls to trigger state changes. There are a couple of different ways you can go about organizing the code for this, but in this case, we’ll just create a custom hook to use with our component which will contain the business logic.

Let’s move our business logic into a custom hook called useCounter that accepts our initial state. Instead of directly exposing dispatch to our consumers, we only expose functions we want our consumers to be able to access. When using this pattern we need to ensure two things. First, we store our action functions on a useRef to avoid them being re-created on every render, which could result in excessive re-rendering in the component tree. Since we are using useRef we want to ensure our functions are pure.

This pattern gives us a few benefits:

  1. We’ve decoupled how we manage our state and how presentational components interact with it. Our Counter component no longer knows if state is managed with useState, useReducer, Redux or something else entirely.
  2. We have the ability to provide convenience functions to our consumers which are pre-configured dispatch functions. You could provide a actions.incrementBy10() if it made sense and it doesn’t require a new reducer.
  3. We have the ability to easily expose computed state to our consumers such as isCountNegative.

Conclusion

With a couple of patterns: such as key-value pair reducer mapping, Immer, and abstraction of business logic we can take our state management to a new level. These patterns allow us to write and organize our code in a clear way and also reduce on the boilerplate that tends to come with using reducers in our features. Hopefully, this will give you some insight into how powerful and flexible reducer can really become.

--

--