React’s useReducer hook is one of the most complex, as well as one of the most elegant. It allows you to group a bunch of individual states together and gives you a very organized, safe, predictable, and easy to debug way to change and interact with that state.
The problem is, a lot of people avoid it because it’s hard to understand, and the official documentation, while not bad, it’s also not the best. I think to really understand useReducer you should take a look at Redux, which is basically useReducer with a lot of convenience features on top.
But Redux is famous for being complex and hard to learn (the official documentation could be a 500 pages book), so people prefer simpler solutions like Zustand. There’s nothing wrong with Zustand or Redux, each have their pros and cons, but by reaching out to some external tool you might be missing out on what React already gives you: The Reducer Pattern.
If you never heard about Redux or useReducer and want an advanced in-depth explanation of all it’s concepts and how it works from scratch, I can’t recommend Dan Abramov’s (Co-Author of Redux and React developer) Fundamentals of Redux Course course enough. It implements Redux from scratch in very small, easy-to-understand steps. Highly recommended! And it’s also free by the way 🙂
That being said, in this post I’ll keep things simple and limit myself only to useReducer.
Why useReducer
The main reason I find myself using useReducer is when I have to update several independent states all at once, and apply some logic when they change.
Because React state is updated asynchronously, you have no guarantee that updates are going to happen one after the other:
setName('Fede');
setAge(34);
console.log('Changed state!');
In the snippet above, the state update for name and age could happen in any order, and both will happen after the call to console.log.
If you need to make sure updates happen all at once, either because you have some business logic you need to apply (eg: If the age is greater than 18, don’t change the name), then you’ll find some issues.
At first you might be able to get around those issues, but as components grow, it can get hard to track what part of the state changes when, and by what part of the code. It can even be impossible to solve an issue without modifying the state all at once (eg: Changing the name and age at the same time, not one after the other).
React’s official documentation elaborates a bit on Extracting State Logic into a Reducer, and the main reason they mention of why one would like to do such a thing is when you have a lot of state for a component and you are not sure of all the different ways the state can change.
Be it because you need to organize yourself better or you need to get rid of some race condition, useReducer is there for you.
How it works
It all starts with the reduce function. That function exists on several programming languages and is core to functional programming. It’s one of the most powerful list operators, because you can implement all other list-processing functions using reduce (map, forEach, find, etc).
The idea of reduce is simple: Take a list of data, and reduce it into a single value, eg: Reduce 1 2 3 4 5 to the sum of all the digits would give you 15. So you reduce a list of things into a single thing.
We can do that in code with:
[1, 2, 3, 4, 5].reduce((accu, value) => accu + value, 0);
The reduce function takes two parameters:
- A function (called reducer), which will be called once for each element in the array
- The initial value for the
accumulator
When the function is called, it will receive both the actual array element, as well as the accumulator. The function should return the new value of the accumulator, so it will be passed along to the next call. Once there’s nothing more to call, the accumulator will be returned.
The term “reducer” comes from this reduce function. When we use useReducer, we are writing the function that’s passed to reduce:
(accu, value) => accu + value
Notice we receive accu (the previous iteration result) as well as a value. Using both parameters, we then return the next value for accu.
The React equivalent would be like this:
function reducer(state, action) {
// ...
}
Instead of receiving accu, we receive the current state (or the initial state if this is the first call), and as second parameter we receive an action. The action can be any object, but to follow the pattern each action should have a type and, if they must specify parameters, they do so in a payload field, which can be anything, null, a string, a number, or an object. Our reducer must return the next state after the action has been applied.
To get a better idea, let’s create a very simple reducer. A good place to start is the actual data. Following React’s documentation example, our state will be a list of tasks:
const initialState = [
{ id: 1, text: 'Plant a tree', done: true },
{ id: 2, text: 'Write a book', done: false },
];
Notice that in this case, our initial state is an array, but our state could also be an object, or even a single value like a string, although that would defeat the purpose of the whole pattern!
In the component we want to use the reducer, we can simply invoke the useReducer hook, passing it our reducer and initialState:
const [tasks, dispatch] = useReducer(reducer, initialState);
Notice that we get back two things from useReduer:
- The actual state, stored in
tasks - A
dispatchfunction we can use to change the state
The only way to change the state is through the dispatch function. An advantage of this pattern is that now all business logic related to the state (eg: don’t create a new task if the text is empty) can live in the reducer, rather than the component.
That’s great, because get to put all business logic in the same place the data lives, making our lives easier in the long run.
Now, the way dispatch works is that we will give it actions, and then our reducer will receive those actions, and modify the state accordingly. This is great because our components can simply say “dispatch the create task action” rather than concerning itself with the business logic needed to actually create a task.
So for example, if we want to create a new task when a button is pressed:
const handleButtonClicked = () => {
dispatch({
type: 'added',
payload: { id: 5, text: "Hello world!" }
});
}
By convention, all actions have a type and a payload, but you could pass in any object and React will just pass it along to your reducer. Speaking of which, our reducer would look something like this:
function reducer(state, action) {
if (action.type === 'added') {
return [
...state,
{ id: action.payload.id, text: action.payload.text, done: false }
];
}
throw new Error(`Invalid action: ${action.type}`);
}
Notice the type of the action is in the past tense ('added'), this is a common naming convention to express that 'added' was an event. In reducers, I personally prefer imperative verbs, eg: add rather than added, because the action has not yet been processed. But that’s just personal preference 🙂
And those are all the concepts you need! A reducer function, the initial state, useState and dispatch. If you want to use the reducer everywhere you can refactor it into a custom hook, although that would create a new state for each component. If you want to share it across all components, you can use the Context API to pass it along, or just use Redux Toolkit.
Below you can see a fork of React’s official example, I simply changed the initial state to make it easier to follow the blog post:
Conclusion
The Reducer pattern is great for grouping related state together, as well a it’s operations. In a way it’s similar to OOP’s concept of an object, where you have data and operations on that data together. In this case, it’s a functional approach, but similar concept: We only export “actions”, as well a read-only immutable state.
This pattern is particularly good at fixing related state issues such as making debugging easier or fixing race conditions, and it also scales pretty well! Although it you need to scale it, consider using Redux Toolkit, as it will come with lots of nice features such as handling async actions and a very fancy debugger so you can see every single action that gets dispatched in your app.
The nice thing is that you can use as much or as little as you need, and you can get quite far without requiring any external libraries like Zustand.