How To Manage Complex State In React With useReducer
June 8, 2020
Before the release of hooks, nearly every React project used Redux to manage complex state interactions. Redux is great for managing complex state transitions and sharing state globally, but with the introduction of the Context API and the useReducer
hook Redux is no longer necessary for handling complex shared state. In my last article I talked about the Context API and the useContext
hook which you can find here. In this article I want to talk about useReducer
and how it is perfect for handling complex state transitions.
If you prefer to learn visually, check out the video version of this article.
From useState
to useReducer
?
useReducer
is the best solution in React for handling complex state interactions so let’s look at how we can convert a component from useState
to useReducer
.
function Counter() {
const [count, setCount] = useState(0)
function changeCount(amount) {
setCount(prevCount => prevCount + amount)
}
function resetCount() {
setCount(0)
}
return (
<>
<span>{count}</span>
<button onClick={() => changeCount(1)}>+</button>
<button onClick={() => changeCount(-1)}>-</button>
<button onClick={() => resetCount()}>Reset</button>
</>
)
}
In the above code we have a very simple counter component which can increment, decrement, and reset the count. In order to start converting this to use the useReducer
hook we first need to remove the useState
call and replace it with useReducer
, but before we can do that we need to understand how useReducer
is called.
Setting Up State
Similar to useState
, useReducer
takes an initial state as one of its arguments and returns to us the current state and a way to update that state. useReducer
also re-renders a component when the state changes just like useState
. The only major difference is that we also need to pass a reducer function to useReducer
which contains all the logic for modifying our state.
const [count, dispatch] = useReducer(reducer, 0)
In the above code you can see that the default state of 0
is passed as the second argument to useReducer
and the count is returned as the first element in the array just like with useState
. Now instead of having a setCount
function we have a dispatch
function which allows us to call the reducer function we pass to useReducer
. This is a little bit complicated to think about in your head so here is a simple example based on our counter.
function reducer(count, action) {
switch (action.type) {
case "increment":
return count + 1
default:
return count
}
}
const [count, dispatch] = useReducer(reducer, 0)
We now have defined the reducer function and it takes two parameters. The first parameter is the current state of our component. In our case this is just our count. The second parameter is our action which is going to be set to whatever you pass to dispatch
. I will cover this more in just a bit. Now inside of the reducer function we have a set of defined actions we can perform on our state. In our case the only action we can perform is the increment action, so if we pass { type: 'increment }
to dispatch
then it will increase our count by one, otherwise the count will not change.
Essentially, the reducer function takes in a current state as well as an action to perform on the state and it returns the new state. Here is the code we would use to increment our counter.
dispatch({ type: "increment" })
Now that we understand how useReducer
initializes and updates state, let’s replace useState
with useReducer
in our counter component.
function reducer(count, action) {
switch (action.type) {
case "increment":
return count + 1
case "decrement":
return count - 1
case "reset":
return 0
default:
return count
}
}
function Counter() {
const [count, dispatch] = useReducer(reducer, 0)
return (
<>
<span>{count}</span>
<button onClick={() => dispatch({ type: "increment" })}>+</button>
<button onClick={() => dispatch({ type: "decrement" })}>-</button>
<button onClick={() => dispatch({ type: "reset" })}>Reset</button>
</>
)
}
As you can see there is no longer any logic inside of our component. The component just tells our reducer what actions to perform and the reducer handles all the complex logic. This is great since it separates out the logic of the state from the component itself and makes it easier to reuse and share this state between components.
What happens if you want to pass data to your reducer, though? This is actually really simple. Since we can pass anything we want to dispatch we can just add our data to the object we pass to dispatch. The common practice is to put all your data inside a property called payload
on your object. Here is an example of how to do that.
function reducer(count, action) {
switch (action.type) {
case "increment":
return count + 1
case "decrement":
return count - 1
case "reset":
return 0
case "change-count":
return count + action.payload.amount
default:
return count
}
}
function Counter() {
const [count, dispatch] = useReducer(reducer, 0)
return (
<>
<span>{count}</span>
<button onClick={() => dispatch({ type: "increment" })}>+</button>
<button onClick={() => dispatch({ type: "decrement" })}>-</button>
<button
onClick={() => {
dispatch({ type: "change-count", payload: { amount: 5 } })
}}
>
Add 5
</button>
<button onClick={() => dispatch({ type: "reset" })}>Reset</button>
</>
)
}
All we had to do in order to add this new action was create a new section in our reducer to handle this new action. Then we added a call to dispatch to call that action and gave it a payload with the amount we want to change our count by.
Cleaning Up Actions
One of the biggest downsides to useReducer
is that all the actions are defined in strings. This makes it easy to accidentally misspell the action type and cause a bug. One easy way to minimize these types of mistakes is to use a constant object to contain all available actions. This then gives you autocomplete on action types and if you are using TypeScript they can be checked by the compiler. Here is a simple example of that.
const ACTIONS = {
INCREMENT: "increment",
DECREMENT: "decrement",
RESET: "reset",
CHANGE_COUNT: "change-count",
}
function reducer(count, action) {
switch (action.type) {
case ACTIONS.INCREMENT:
return count + 1
case ACTIONS.DECREMENT:
return count - 1
case ACTIONS.RESET:
return 0
case ACTIONS.CHANGE_COUNT:
return count + action.payload.amount
default:
return count
}
}
function Counter() {
const [count, dispatch] = useReducer(reducer, 0)
return (
<>
<span>{count}</span>
<button onClick={() => dispatch({ type: ACTIONS.INCREMENT })}>+</button>
<button onClick={() => dispatch({ type: ACTIONS.DECREMENT })}>-</button>
<button
onClick={() => {
dispatch({
type: ACTIONS.CHANGE_COUNT,
payload: { amount: 5 },
})
}}
>
Add 5
</button>
<button onClick={() => dispatch({ type: ACTIONS.RESET })}>Reset</button>
</>
)
}
Conclusion
useState
is a great way to setup simple state inside of a component. When state starts to get more complex, though, and is shared between multiple components it is generally best to switch to useReducer
since useReducer
makes it easier to write complex state interactions without creating a large complex mess of code.