Hirefullstack – Software Engineering & IT-Beratung aus Berlin
← Back to overview
Core 12 min read

useReducer

When “a couple of fields” has turned into a small machine

Builds on: useState
In one sentence

Instead of calling setState in ten places, you write down in one place how the state changes for each event. The component only reports what happened.

useReducer is the big brother of useState. Instead of “set this value to that”, you say “the following happened” – and a single function decides what comes of it.

Put another way

useState is a switch you flip yourself. useReducer is an order: you call out “one pizza” – the recipe decides what happens in the kitchen. And all the recipes live in one place.

shape.ts
const [state, dispatch] = useReducer(reducer, initial);
//     ▲       ▲                        ▲
//     |       |                        your function: (old, event) => new
//     |       report "this happened"
//     the current state

dispatch({ type: "added", text: "Milk" });
“Reducer” is just the name for the function that turns old state plus an event into new state.

A complete example

todos.tsx
type Todo = { id: number; text: string; done: boolean };

// Everything that can happen
type Action =
  | { type: "added"; text: string }
  | { type: "toggled"; id: number }
  | { type: "cleared" };

// What comes of it – an ordinary function
function reducer(todos: Todo[], action: Action): Todo[] {
  switch (action.type) {
    case "added":
      return [...todos, { id: Date.now(), text: action.text, done: false }];

    case "toggled":
      return todos.map((t) => (t.id === action.id ? { ...t, done: !t.done } : t));

    case "cleared":
      return [];

    default:
      return todos;
  }
}

function App() {
  const [todos, dispatch] = useReducer(reducer, []);

  return <button onClick={() => dispatch({ type: "cleared" })}>Delete all</button>;
}
Tip Name events, not setters

Use { type: "form_submitted" } rather than { type: "setLoading" }. One event can set several fields correctly at once (loading: true, error: null) – a setter only knows one and leaves gaps.

The reducer has to behave

changes the old thing, reaches outside
function reducer(state, action) {
  state.count += 1;                // changes the old thing!
  localStorage.setItem("c", "1");  // reaches outside!
  return state;                    // same object -> no redraw
}
only computes and returns something new
function reducer(state, action) {
  return { ...state, count: state.count + 1 };
}

// Reaching outside is the click handler's job:
function click() {
  dispatch({ type: "incremented" });
  localStorage.setItem("c", String(state.count + 1));
}

The reducer must be pureA function is “pure” if it always returns the same thing for the same input and touches nothing else.Your components must be pure while rendering – then React can safely call them more than once.: compute from the inputs, return a new result, touch nothing else. React deliberately calls it twice in development – with a well-behaved function nobody notices.

useState or useReducer?

Reach for it when …
  • +

    Several fields change together (loading, data, error)

  • +

    The next change depends on the current state in a complicated way

  • +

    The same logic is triggered from many places (a wizard, a game, undo)

  • +

    You want to test the logic without drawing anything

Skip it when …
  • A single switch or counter – useState is shorter and clearer

  • When the reducer only forwards state.field = action.value: that is useState with extra steps

Good to know Handy: dispatch never changes

Unlike functions you write yourself, dispatch stays the same forever. So you can hand it deep down or put it in effect lists without a second thought.

In more depth: computing the initial value and the never trick optional

useReducer takes a third parameter: a function that computes the starting value on the first pass only. Handy when building it is expensive.

lazy-init.tsx
function init(count: number) {
  return { fields: Array(count).fill(""), error: null };
}

const [state, dispatch] = useReducer(reducer, 5, init);
//                                             ▲   ▲
//                                             |   called once with 5
//                                             argument for init

And a useful TypeScript trick: declare a variable of type never in the default branch and the compiler complains as soon as you forget an action.

never.ts
default: {
  const _check: never = action;   // error if a case is missing
  return state;
}

Does it stick?

4 questions on this lesson. Wrong answers show up in your stats.

Now write it yourself

A shopping cart as a reducer

Finish the reducer. The cart should add items (an item already there just goes up in quantity), decrease quantities and be emptied completely.

  • add creates a new item with quantity 1
  • The same item again raises the quantity to 2
  • decrease lowers the quantity; at 0 the item drops out
  • clear removes everything
import { useReducer } from "react";

type Line = { name: string; qty: number };
type Action =
  | { type: "add"; name: string }
  | { type: "decrease"; name: string }
  | { type: "clear" };

function reducer(state: Line[], action: Action): Line[] {
  switch (action.type) {
    case "add":
      // TODO
      return state;
    case "decrease":
      // TODO
      return state;
    case "clear":
      // TODO
      return state;
    default:
      return state;
  }
}

export default function App() {
  const [cart, dispatch] = useReducer(reducer, []);

  return (
    <div style={{ fontFamily: "system-ui", padding: 16 }}>
      <button onClick={() => dispatch({ type: "add", name: "Apple" })}>
        Apple +
      </button>{" "}
      <button onClick={() => dispatch({ type: "decrease", name: "Apple" })}>
        Apple −
      </button>{" "}
      <button onClick={() => dispatch({ type: "clear" })}>Clear</button>

      <ul>
        {cart.map((l) => (
          <li key={l.name} data-testid={"line-" + l.name}>
            {l.name}: {l.qty}
          </li>
        ))}
      </ul>
    </div>
  );
}

Hirefullstack

Need React firepower on your team?

We have been building React and Next.js applications for clients across Germany for years – as a single expert, as reinforcement for an existing team, or as a complete Scrum team.

Talk about your project →