Kodokon kodokon.com

useReducer for complex state

Structure complex state with a pure reducer that centralizes transitions and rules out impossible combinations.

9 min · 3 questions

Open this lesson in Kodokon

As soon as a component orchestrates several state values that evolve together - a request status, some data, an error - multiplying useState calls scatters the transition logic across every event handler. You know the symptom: three setters to call in the right order, and sooner or later an inconsistent state, like a status of success sitting next to a non-null error. useReducer flips the responsibility: the state becomes a single object, and every change goes through an action interpreted by one central function, the reducer.

JAVASCRIPT
function requestReducer(state, action) {
  switch (action.type) {
    case "start":
      return { status: "loading", data: null, error: null };
    case "resolve":
      return {
        status: "success",
        data: action.data,
        error: null,
      };
    case "reject":
      return {
        status: "error",
        data: null,
        error: action.error,
      };
    default:
      throw new Error("Unknown action: " + action.type);
  }
}
Each transition rebuilds a complete, consistent state.

The reducer is a pure function: it receives the current state and an action, then returns the next state, with no side effects. The benefits are immediate: transitions are explicit and exhaustive, impossible states disappear at the source (each case rebuilds a consistent object), and the logic reads in a single place instead of being scattered around. A useful warning sign: if you find yourself copy-pasting the same sequence of setState calls in two places, you are probably missing a reducer.

JSX
function UserProfile() {
  const [state, dispatch] = useReducer(requestReducer, {
    status: "idle",
    data: null,
    error: null,
  });

  async function load(id) {
    dispatch({ type: "start" });
    try {
      const res = await fetch("/api/users/" + id);
      const data = await res.json();
      dispatch({ type: "resolve", data });
    } catch (error) {
      dispatch({ type: "reject", error });
    }
  }

  return <button onClick={() => load(1)}>Load</button>;
}
The component expresses the intent, the reducer decides the state.

Choosing between useState and useReducer is not a matter of taste. Keep useState for simple, independent values; switch to useReducer when the next state depends on the previous one or when several fields change together. Two extra advantages: the third argument provides lazy initialization (useReducer(reducer, arg, init) only runs init on the first render), and React guarantees that dispatch keeps the same identity from one render to the next - you can pass it down to children without useCallback.

Knowledge check

Make sure you remember the key points of this lesson.

  1. Which signal should point you toward useReducer rather than multiple useState calls?
    • The component exceeds a hundred lines
    • Several state values change together and the new state depends on the previous one
    • The component receives more than three props
    • The state contains an array
  2. A reducer returns the state object it received, mutated in place. What does React do?
    • It compares the old and new references, finds them equal, and skips the re-render
    • It detects the deep mutation and re-renders the component
    • It throws an error because state is frozen in development
  3. Why can you pass dispatch to a memoized child without any special precautions?
    • Because React.memo ignores function-typed props
    • Because dispatch is recreated but structurally equal on every render
    • Because React guarantees that dispatch keeps the same identity for the component's entire lifetime