Skip to content
Topic 9 of 13
Theory

useReducer: complex state

When useState isn't enough

While state is one or two values, useState is perfect. But in real applications logic grows quickly: a task list where you can add, check, delete, filter, clear. The code that changes the state spreads over a dozen handlers in different places of the component, and it's already hard to understand how the state can change at all.

useReducer gathers all the change logic into one function — the reducer. Event handlers no longer say "how to change", they only report what happened.

Actions and the reducer

function tasksReducer(tasks, action) {
  switch (action.type) {
    case "added":
      return [...tasks, { id: action.id, text: action.text, done: false }];
    case "toggled":
      return tasks.map((t) => (t.id === action.id ? { ...t, done: !t.done } : t));
    case "deleted":
      return tasks.filter((t) => t.id !== action.id);
    default:
      throw new Error("Unknown action: " + action.type);
  }
}
  • An action is an ordinary object that describes an event. By convention it has a type field, and the rest is data: { type: "deleted", id: 3 }.
  • A reducer is a function (currentState, action) → newState. The name comes from the array method reduce: it also "folds" a sequence into one value.

A reducer can be checked without React, like an ordinary function:

let state = [];
state = tasksReducer(state, { type: "added", id: 1, text: "Buy bread" });
state = tasksReducer(state, { type: "added", id: 2, text: "Call mom" });
state = tasksReducer(state, { type: "toggled", id: 1 });
console.log(state);

useReducer in a component

const { useReducer } = React;

function TaskApp() {
  const [tasks, dispatch] = useReducer(tasksReducer, []);

  return (
    <div>
      <button onClick={() => dispatch({ type: "added", id: Date.now(), text: "New" })}>
        Add
      </button>
      <ul>
        {tasks.map((t) => (
          <li key={t.id}>
            <span onClick={() => dispatch({ type: "toggled", id: t.id })}>{t.text}</span>
            <button onClick={() => dispatch({ type: "deleted", id: t.id })}>×</button>
          </li>
        ))}
      </ul>
    </div>
  );
}
  • useReducer(reducer, initialState) returns a pair: the current state and the dispatch function.
  • dispatch(action) — "send an action". React calls the reducer with the current state and this action, saves the result and re-renders the component.

Compare with useState: there the handler decides itself what the new state will be. Here the handler only reports the event, and the reducer decides.

Rules for a reducer

A reducer must be a pure function:

  1. Don't change the incoming state — return a new array or object (as with useState, otherwise React won't see the changes).
  2. No side effects: no requests, timers, Math.random() or Date.now() inside. A random id is better generated in the handler and passed in the action.
  3. For an unknown action — throw an error. A typo in type becomes noticeable right away.

Purity gives a big advantage: the result depends only on the input, so a reducer is easy to test, and the behaviour of the application can be reproduced by repeating the same actions.

If you need to declare a variable in a case, wrap it in curly braces — then the variables of different branches won't conflict:

case "added": {
  const id = Math.max(0, ...tasks.map((t) => t.id)) + 1;
  return [...tasks, { id, text: action.text, done: false }];
}

useState or useReducer?

useState useReducer
How much code less more (reducer + actions)
Simple values (a flag, a field's text) ✓ overkill
Many ways to change one state handlers spread out all the logic in one place
Testing the logic together with the component the reducer separately, as a function
Debugging — you can log every action to the console

A good rule: start with useState. If there are many handlers changing the same state, switch to useReducer. It's easy to do: the actions describe what already happens in the handlers.

Reducer + context

useReducer and useContext together are the standard pattern for application-wide state without external libraries:

const TasksContext = createContext(null);
const DispatchContext = createContext(null);

function TasksProvider({ children }) {
  const [tasks, dispatch] = useReducer(tasksReducer, []);
  return (
    <TasksContext.Provider value={tasks}>
      <DispatchContext.Provider value={dispatch}>
        {children}
      </DispatchContext.Provider>
    </TasksContext.Provider>
  );
}

function DeleteButton({ id }) {
  const dispatch = useContext(DispatchContext);
  return <button onClick={() => dispatch({ type: "deleted", id })}>×</button>;
}
  • State and logic are in one place (TasksProvider + the reducer).
  • Any component at any depth reads the tasks (TasksContext) or sends actions (DispatchContext), without props.
  • Why two contexts? The dispatch function never changes, so components that need only dispatch (buttons) won't re-render on every change of the list.

Popular state libraries — Redux and Zustand — are built in a similar way: a store, actions and pure update functions. Once you understand useReducer, you'll understand them too.

Summary

  • useReducer(reducer, initial) → [state, dispatch].
  • An action is an object { type, ...data }, it describes what happened.
  • A reducer is a pure function (state, action) => newState: no mutations or effects, an unknown action is an error.
  • useState — for simple things, useReducer — when there are many ways to change the state.
  • A reducer + two contexts (the state and dispatch) — the state of the whole application.

Counter reducer

Easy+10 XP

Write a pure function counterReducer(state, action). The state is an object { count }. Actions:

  • { type: "increment" } — plus 1;
  • { type: "decrement" } — minus 1;
  • { type: "add", amount } — plus amount;
  • { type: "reset" } — count becomes 0;
  • anything else — throw an Error with the message Unknown action: <type>.

The function returns a new object and doesn't change state.

counterReducer({ count: 5 }, { type: "increment" })        → { count: 6 }
counterReducer({ count: 5 }, { type: "add", amount: 10 })  → { count: 15 }
counterReducer({ count: 5 }, { type: "jump" })             → Error: Unknown action: jump

Connect the connector to solve tasks

Task code runs on your own computer — faster and more reliable than in the browser. This needs the free CodeWithMe connector app. You only connect it once.

Looking for the connector on this computer…

  1. 1
    Download the connector for your system (Windows, macOS or Linux).Download the connectorOn the download page pick the file with your system in its name, e.g. windows-amd64.
  2. 2Run it and choose a folder for your solutions. It will install Python and Node.js there by itself when needed.
  3. 3
    Enter below the 6-digit code shown in the connector window (“Code for CodeWithMe”). If the browser asks about local network access — allow it.

The connector is open source (on GitHub), works only on your computer and runs code only after you enter the pairing code.