Next Level Reducers
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:
- You have portions of your state which can depend on other elements of your state
- 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 thedraft
that is passed in. The interesting thing about Immer is that thebaseState
will be untouched, but thenextState
will reflect all changes made todraftState
.
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:
- 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 withuseState
,useReducer
,Redux
or something else entirely. - 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. - 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.