320H.6 - React Hooks: useReducer

Learning Objectives

By the end of this lesson, learners will be able to:

  • Explain the purpose of reducer functions.
  • Describe the interaction between dispatch and action objects.
  • Use the useReducer hook to manage state with reducer functions.
  • Explain the difference between useState and useReducer.
  • Explain the use cases for useState and useReducer.
  • Creating mutating reducer functions with the useImmerReducer hook.

 CodeSandbox

This lesson uses CodeSandbox as one of its tools.

If you are unfamiliar with CodeSandbox, or need a refresher, please visit our reference page on CodeSandbox for instructions on:

  • Creating an Account
  • Making a Sandbox
  • Navigating your Sandbox
  • Submitting a link to your Sandbox to Canvas

The useReducer Hook

The useReducer hook is an alternative to the useState hook for state management. It allows you to handle changes to state in a different way, making use of reducer functions that take state-setting logic out of your components and centralize that logic so that it can be more easily controlled and maintained.

Reducer functions are functions that accept the current state and an action object as arguments. From these two arguments, the reducer calculates what the next state value should be and returns it, or throws/handles errors that may occur.

The basic syntax for useReducer is:

[state, dispatch] = useReducer(reducer, initialArg, init?)

useReducer takes the following arguments:

  • reducer is the reducer function being used to modify state.
  • initialArg is the value from which the initial state is calculated (or the initial state itself in the absence of init).
  • init is an optional initializer function that returns the initial state using initialArg as its sole argument.

The returns from useReducer are very similar to useState:

  • A state variable containing state information.
  • A dispatch function that is used to "set" the state by dispatching actions, a concept we'll discuss more soon.

Here's what a basic implementation of useReducer might look like:

import { useReducer } from 'react';

function reducer(state, action) {
  // ...
}

function MyComponent() {
  const [state, dispatch] = useReducer(reducer, { someValue: 42 });
  // ...

Dispatching Actions

When utilizing reducers, instead of directly "setting the state" with a set function, we "dispatch actions" with a dispatch function. Instead of telling React what to do by setting state, we tell it what the user did by dispatching an action.

Imagine a large codebase full of state-setting code without context as to why we're setting the state. What is causing the change? How can we handle similar changes in the future without repeating code?

Reducers allow us to answer these questions.

An action is simply a JavaScript object that describes what change we want to happen, rather than how we want a change to be made. The reducer function will handle the how later. If we think about building a simple counter application, we might have actions that describe the counter incrementing or decrementing.

These actions are described using a type attribute, and "dispatched" (sent) via the dispatch function returned from useReducer.

Here's what incrementing and decrementing a simple counter might look like:

const [count, dispatch] = useReducer(reducer, 0);

dispatch({ type: "increment" });
dispatch({ type: "decrement" });

Reducer Functions

Any and all logic for updating the state should be included in the reducer functions. Reducers must be pure functions, but they can (and should) contain as much state-setting logic as possible - it's their entire purpose.

By convention, reducers commonly handle actions with a switch statement.

Here's an example reducer function for our hypothetical counter application:

function reducer(state, action) {
  switch (action.type) {
    case "increment": {
      return state + 1;
    }
    case "decrement": {
      return state - 1;
    }
    default: {
      throw Error("Unknown Action: " + action.type);
    }
  }
}

By moving the logic for increment and decrement into the reducer, we avoid potential bugs within our components themselves.

Imagine we had 10 different places where we increment our counter using a traditional set function, and in one of them we accidentally typed + 2 instead of + 1. While this is a simple and silly example that would be easy to track down, it still highlights the issue: inconsistency.

Reducer functions allow you to provide guaranteed, consistent behavior for actions. As the logic within the reducers get more complicated, you can imagine how this could reduce bugs and the need to rewrite code.

Let's move our counter example out of the hypothetical world and into the real one to see how it works:

There are a few things to note within this example:

  • We've created an ActionButton component that uses dispatch, type, and payload props to dispatch actions for us (more on payload soon).
  • ActionButtton does not require access to the count state; this is because dispatch automatically sends its associated state to the reducer function.
  • We can return state + 1 and state - 1 within the reducer because numbers are primitive data types. The same rules for immutable state from useState apply with useReducer, so if our state was an object or array, we would need to return a new state, not modify the existing one.

The payload we are referencing within our action object is any additional data that the reducer needs to calculate the new state. While an action object can have any shape, by convention they are created with two properties: type and payload. Altogether, the action should include the minimal necessary information to calculate the new state.

It is also important to note that each action describes a single interaction, even if it leads to multiple changes in the data. For example, if a user presses “Reset” on a form with five fields managed by a reducer, it makes more sense to dispatch one reset_form action rather than five separate set_field actions.

Here's an extended example of the counter application where we include an input field to modify the increment and decrement step size:

Notice how we use useState in the example above to handle the step state that is being bound to our NumberInput <input> element.

You can also organize your code to separate reducer logic from component structure by moving reducers into their own files, which is a common practice when either the component or its reducer becomes particularly complicated. We could import counterReducer from './counterReducer.js' if we wanted to (of course, we'd need to put it there first).

useReducer is not better than useState in every case. useReducer is best utilized when you have many state updates spread across many event handlers, so that they can all be consolidated into one place. In the case of our example above, we only use one onChange event handler to handle the step state, so useState is perfectly fine.


useState vs. useReducer

Reducers are not without downsides! Here’s a few ways you can compare useState and useReducer, taken from the React documentation:

  • Code Size: Generally, with useState you have to write less code upfront. With useReducer, you have to write both a reducer function and dispatch actions. However, useReducer can help cut down on the code if many event handlers modify state in a similar way.
  • Readability: useState is very easy to read when the state updates are simple. When they get more complex, they can bloat your component’s code and make it difficult to scan. In this case, useReducer lets you cleanly separate the how of update logic from the what happened of event handlers.
  • Debugging: When you have a bug with useState, it can be difficult to tell where the state was set incorrectly, and why. With useReducer, you can add a console log into your reducer to see every state update, and why it happened (due to which action). If each action is correct, you’ll know that the mistake is in the reducer logic itself. However, you have to step through more code than with useState.
  • Testing: A reducer is a pure function that doesn’t depend on your component. This means that you can export and test it separately in isolation. While generally it’s best to test components in a more realistic environment, for complex state update logic it can be useful to assert that your reducer returns a particular state for a particular initial state and action.
  • Personal Preference: Some people like reducers, others don’t. That’s okay. It’s a matter of preference. You can always convert between useState and useReducer back and forth: they are equivalent!

The React team recommends using a reducer if you often encounter bugs due to incorrect state updates in some component, and want to introduce more structure to its code. You don’t have to use reducers for everything: feel free to mix and match! You can even useState and useReducer in the same component, as shown in the counter example above.

Just like with useState, state updates called with dispatch do not change the state in the currently running code. State always behaves like a snapshot, whether you're using useState or useReducer. Unlike with useState, however, if we need to guess at the next state value for whatever reason, we can calculate it manually by calling the reducer function ourselves:

const action = { type: 'increment' };
dispatch(action);

const nextState = reducer(count, action);
console.log(state);     // 0
console.log(nextState); // 1

useReducer Example

In order to demonstrate everything we've learned so far in a single example, we've created an application that tracks the health of a standard adventuring party. This application is split into many files in order to make it more digestible; please explore each of them.

The example application includes:

  • useState and useReducer
  • Props and inverse data flow.
  • State-controlled input fields.
  • Conditional rendering and conditional styling.
  • A detailed reducer function with many actions.
  • Many other small but useful tricks.

Take some time to look through the sandbox, and try making modifications to see how different things behave. Be creative! You will be working on a very similar (though more practical) application very shortly.


Mutating State with Immer Reducers

As a reminder, the Immer library is a small package that allows state updates to look mutable, while still enforcing immutable state changes.

Just like useState has an accompanying useImmer hook, useReducer can be replaced with useImmerReducer from use-immer, allowing us to write mutating state logic inside of our reducers. The syntax remains the same!


Practice Activity: Writing Concise Reducers with Immer

Fork the CodeSandbox example above.

Inside of App.js, import { useImmerReducer } from "use-immer", and replace useReducer with useImmerReducer.

Afterwards, refactor the existing reducer function in partyReducer.js to use mutating logic.

Compare your results with your peers, and discuss the different options Immer provides for making this particular example cleaner and more concise.


Building a Todo List

Now that you have a solid understanding of handling state and generating interactivity in React, it's time to put together a small but useful application from scratch.

ALAB 320H.6.1 - Building a Todo List will give you instructions for building a todo list applications that has specific functionality.

If you need help during the lab activity, first reference relevant documentation, then speak to one of your peers, then seek out an instructor if all else fails.

Copyright © Per Scholas 2024