June 06, 2022

Why Use useReducer?

edit

A while back now, my buddy Mike Hartington asked this:

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.


Liked the post?
Give the author a dopamine boost with a few "beard strokes". Click the beard up to 50 times to show your appreciation.
Kyle Shevlin's face, which is mostly a beard with eyes

Kyle Shevlin is the founder & lead software engineer of Agathist, a software development firm with a mission to build good software with good people.

Logo for Just Enough Functional Programming
Just Enough Functional Programming
Check out my courses!
If you enjoy my posts, you might enjoy my courses, too. Click the button to view the course or go to Courses for more information.
Sign up for my newsletter
Let's chat some more about TypeScript, React, and frontend web development. Unsubscribe at any time.