Why Use useReducer
?
A while back now, my buddy Mike Hartington asked this:
Random question for my react friends...
— Mike Hartington (@mhartington) February 12, 2022
Is useReducer something folks use/like?
I gave a brief answer in the replies, but thought I’d write a bit more about it here. If you’ve come across my blog before, much of what I’m going to write in this post will relate to other posts of mine, such as useEncapsulation
and Enumerate, Don’t Booleanate and more. There’s a lot of overlap, so please check them out if you haven’t. Forgive me if this all sounds familiar.
Two state primitives
React provides two state primitives, useState
and useReducer
for managing state for a component or custom hook. useState
is a hook that gives us a straightforward [read, write]
tuple. Like so:
const [count, setCount] = React.useState(0)
console.log(count) // "read" the value
setCount(count + 1) // "write" the next value
The other primitive, useReducer
is a bit more complex. It gives us a [read, emitEvent]
tuple. Like so:
const reducer = (state, event) => {
switch (event) {
case 'INCREMENT':
return state + 1
default:
return state
}
}
const [count, emitEvent] = React.useReducer(reducer, 0)
console.log(count) // "read" the value
emitEvent('INCREMENT') // emit the event
Now, more often you’ll see emitEvent
and event
named dispatch
and action
respectively. I, too, often do this. However, I wanted to make it clear that what we’re doing when we use useReducer
is emitting and responding to events.
Choosing one over the other
In general, I treat useState
as the default tool for the state management job. In many situations, I have a single state to manage and writing a few declarative state updaters for that state does the trick. That said, there are certain conditions that make useReducer
a better choice. Here are the things I consider when making the choice:
- Does the next state frequently depend upon the current state?
- Is there more than one state to manage?
- More importantly, are there situations where we change multiple states as a result of the same event?
- Are there times where the same user event is handled differently depending on the current state?
- Far less common, is there a use case where a consumer of the component may need to hook into and respond to events, aka the state reducer pattern?
If you answered “Yes” to any of these questions, then you might have a good use case for using useReducer
. Let’s work through an example to learn more.
Our example
Here I have a little jumping ball. Go ahead. Make it jump by clicking the button labeled “Jump”.
Every time we click the button, the ball jumps. If we click it while the ball is already in the “air”, it has no effect on the ball, which continues to fall due to “gravity”. Try it. Click it as many times as you can while it’s in the “air”.
Nothing, right?
Let’s focus in on some key parts of the code that represent what happens when we click the “Jump” button”. First, we need to establish some states. We’re going to use a combination of useState
and useRef
. We’re going to use useState
for states that we want the component to rerender when it changes, and refs for states that don’t need to cause a rerender. I explain this more in depth in Comparing useRef
and useState
.
function JumpingBall() {
// Y-position of the ball
const [position, setPosition] = React.useState(0)
// The amount of change the ball is experiencing for a given `tick`
const delta = React.useRef(0)
// The state of the ball, either 'idle' or 'jumping'
const ballState = React.useRef('idle')
}
Next, we’re going to implement a tick
function, which will attempt to update the ball’s position
with every “frame” of our component. This is a rudimentary example of a game loop, if you’re familiar with video game development.
const tick = React.useCallback(() => {
// If the ball is 'idle', then there is nothing to update
if (ballState.current === 'idle') return
// Otherwise, set the next position based on the current position
setPosition(currentPosition => {
// Every tick subtracts a constant of GRAVITY from the delta change
delta.current -= GRAVITY
// The nextPosition is the current position + this change. Rounding is just
// for making the ground check easier.
const nextPosition = Math.floor(currentPosition + delta.current)
// If the ball is on the ground (or below it), set the ball on the ground
// and update its state
if (nextPosition <= 0) {
delta.current = 0
ballState.current = 'idle'
return 0
}
// Otherwise, return the calculated next position
return nextPosition
})
}, [])
Next, we need to handle our “Jump” button’s click event.
const handleClick = React.useCallback(() => {
// This is why clicking multiple times does nothing, if its 'jumping'
// do nothing
if (ballState.current === 'jumping') return
// Otherwise, add the force of the jump to the delta
delta.current += JUMP_IMPULSE
// and change the ball state
ballState.current = 'jumping'
}, [])
Lastly, we need to set up an effect to run our loop at ~60fps.
React.useEffect(() => {
// Call `tick` roughly 60 times a second
const id = setInterval(tick, 1000 / 60)
// Clean it up if `tick` changes or we unmount the component
return () => clearInterval(id)
}, [tick])
Here is the component in its entirety now:
function JumpingBall() {
const [position, setPosition] = React.useState(0)
const delta = React.useRef(0)
const ballState = React.useRef('idle')
const tick = React.useCallback(() => {
if (ballState.current === 'idle') return
setPosition(currentPosition => {
delta.current -= GRAVITY
const nextPosition = Math.floor(currentPosition + delta.current)
if (nextPosition <= 0) {
delta.current = 0
ballState.current = 'idle'
return 0
}
return nextPosition
})
}, [])
const handleClick = React.useCallback(() => {
if (ballState.current === 'jumping') return
delta.current += JUMP_IMPULSE
ballState.current = 'jumping'
}, [])
React.useEffect(() => {
const id = setInterval(tick, 1000 / 60)
return () => clearInterval(id)
}, [tick])
// ... and our UI is returned down here
}
Sub-optimal elements of our code
Looking at this code, I see a few things that I think are sub-optimal, but very common in codebases, that we can improve.
The primary issue I see is that we have code that manages the state in two different places, inside of tick
and handleClick
. In order to understand this code, we’d have to read through both of these functions and understand how they relate to one another. Most worrisome to me is having state modifying functionality directly in the event handler. As silly as it sounds, I’d prefer to see it separated into a state updater function called inside the event handler. Like so:
const tryJump = () => {
if (ballState.current === 'jumping') return
delta.current += JUMP_IMPULSE
ballState.current = 'jumping'
}
const handleClick = () => {
tryJump()
}
That way if anything else needs to be called with the event handler, such as logging, we don’t need to make any changes to the state updating code as well.
If we look deeper and consider the questions I prompted before, we recognize that we answer in the affirmative for several of them. Our state updates depend on the current state and we’re updating multiple states at the same time. Let’s refactor our code with a useReducer
pattern instead.
The refactor
First, let’s gather our states into an initialState
object.
const initialState = {
ballState: 'idle'
delta: 0,
position: 0,
}
Next, let’s write our reducer
. I’ll start with it essentially empty, and add our events one by one:
const reducer = (state, event) => {
switch (event) {
default:
return state
}
}
Let’s add the CLICK
event:
const reducer = (state, event) => {
switch (event) {
case 'CLICK': {
if (state.ballState === 'jumping') return state
return {
...state,
ballState: 'jumping'
delta: state.delta + JUMP_IMPULSE,
}
}
default:
return state;
}
}
Now let’s add our TICK
event:
const reducer = (state, event) => {
switch (event) {
case 'CLICK': {
if (state.ballState === 'jumping') return state
return {
...state,
ballState: 'jumping'
delta: state.delta + JUMP_IMPULSE,
}
}
case 'TICK': {
if (state.ballState === 'idle') return state
const nextDelta = state.delta - GRAVITY
const nextPosition = state.position + nextDelta
if (nextPosition <= 0) {
return initialState
}
return {
ballState: 'jumping'
delta: nextDelta,
position: nextPosition
}
}
default:
return state;
}
}
Now, let’s use them in our component.
function JumpingBall() {
const [state, dispatch] = React.useReducer(reducer, initialState)
const tick = React.useCallback(() => {
dispatch('TICK')
}, [])
const handleClick = React.useCallback(() => {
dispatch('CLICK')
}, [])
React.useEffect(() => {
const id = setInterval(tick, 1000 / 60)
return () => clearInterval(id)
}, [tick])
}
Notice how much simpler our event handlers are. Heck, we even discovered we had an event that probably wasn’t obvious to us before, TICK
. Additionally, there’s no logic in multiple functions we have to wrangle. It’s all encapsulated within the reducer
.
Adding a “double jump” to our ball
A popular video game mechanic is to enable a “double jump”, that is, a second upward impulse while the player is jumping. For our case, we want the user to be able to click “Jump” a second time, while the ball is in the “air” and have it jump again. But only one additional time.
If we were still using useState
, we’d have to find a way to add this logic inside of the event handler. You can maybe see where this is going. We’d have to add another state to track, and an additional conditional inside of handleClick
. It wouldn’t be a very organized way to add functionality.
By using useReducer
, literally nothing changes about JumpingBall
. The event handlers still dispatch the same events. The only thing that changes is our reducer
, so let’s update it to handle a double jump.
We’re going to start by adding another state to track, jumpsRemaining
. This way it will be trivial to add a “triple jump” or more if we ever want to in the future. Like so:
const initialState = {
ballState: 'idle',
delta: 0,
jumpsRemaining: 2,
position: 0,
}
Next, we’ll add a check for jumpsRemaining
to our CLICK
event:
const reducer = (state, event) => {
switch (event) {
case 'CLICK': {
if (state.jumpsRemaining === 0) {
return state
}
return {
...state,
ballState: 'jumping',
delta: state.delta + JUMP_IMPULSE,
jumpsRemaining: state.jumpsRemaining - 1,
}
}
// ...
}
}
Lastly, we need to update our TICK
event to update jumpsRemaining
when we hit the “ground”.
// Not a mistake, purposely empty
Wait! There’s nothing to update. It’s already handled by setting state
to initialState
when the ball hits the ground. Yet another benefit of using a reducer is that occasionally we can update multiple states in one fell swoop by resetting it to initialState
(or some other permutation of the state object).
Now let’s see how our ball double jumps!
If you time it right, you can send the ball flying off the canvas. Pretty cool how easy that change was to make.
Summary
useReducer
may seem like overkill, but is an absolutely great tool in the right situation. It can drastically reduce the complexity of our event handlers, while also organizing our state managing code into a single place. Hope this post helps you in choosing which state primitive to use in the future.
As always, you can find the full code for the JumpingBall
(with both state management patterns) by viewing the source code on Github. Look for the why-use-use-reducer
directory under published posts.