Skip to the chapter
←ReactGGroundwork⚛React
React/Intermediate/I3
14 / 57 written chapters
/

The cover
  1. B1Setup & JSX
  2. B2Thinking in React
  3. B3Components
  4. B4Props
  5. B5useState
  6. B6Events & conditionals
  7. B7Lists & keys
  8. B8Forms
  9. B9useEffect
  10. B10Lifting state & styling
  11. B11Guided project
  12. ★Cheat page
  1. I1useRef
  2. I2Context
  3. I3useReducer
  4. I4Custom hooks
  5. I5Effects in depth
  6. I6Routing
  7. I7State architecture
  8. I8Memoisation
  9. I9Boundaries & portals
  10. I10Data fetching
  11. I11Forms at scale
  12. I12State libraries
  13. I13Auth
  14. I14TanStack Query
  15. I15Testing
  16. I16TypeScript
  17. I17Animation
  18. I18E2E testing
  19. I19i18n
  20. I20File uploads
  21. I21Real-time UI
  22. I22shadcn/ui
  23. ★Cheat page
  1. A1Fiber
  2. A2Concurrent
  3. A3Suspense
  4. A4Server Components
  5. A5Server Actions
  6. A6use()
  7. A7Rendering strategies
  8. A8Effect timing
  9. A9External stores
  10. A10Refs in depth
  11. A11Component patterns
  12. A12Performance
  13. A13Accessibility
  14. A14Design systems
  15. A15Advanced TypeScript
  16. A16Architecture at scale
  17. A17Custom renderers
  18. A18Migrating & upgrading
  19. A19Production debugging
  20. A20Review & judgement
  21. A21State machines
  22. A22Feature flags
  23. A23Interview bank
  24. ★Cheat page

⚛I3 · Part 2 · Intermediate

useReducer

When the next state depends on the current state and on which thing happened.

5 min read12 sections4 exercises
14 of 57

In this chapter

  • The shape
  • Why bother, when useState exists
  • The example that makes the case
  • Naming actions after events, not setters
  • Why purity is enforced, not suggested

and 7 more

about 5 min
12On this page
  1. The shape
  2. Why bother, when useState exists
  3. The example that makes the case
  4. Naming actions after events, not setters
  5. Why purity is enforced, not suggested
  6. Where the side effects go
  7. Testing it
  8. Lazy initialisation
  9. dispatch is stable, and that matters
  10. Seeing every action while debugging
  11. Reducer plus context: the poor man's store
  12. Keeping it immutable

The shape

function reducer(state, action) {
  switch (action.type) {
    case "increment": return { ...state, count: state.count + 1 };
    case "reset":     return { count: 0 };
    default:          throw new Error("Unknown action: " + action.type);
  }
}

const [state, dispatch] = useReducer(reducer, { count: 0 });

dispatch({ type: "increment" });

A reducer is a pure function: given the current state and a description of what happened, it returns the next state. It must not mutate its argument, and it must not fetch, log, or set timers — React may call it more than once for a single dispatch.

Like a state setter, dispatch does not change state in the code that is already running. Log state right after dispatching and you see the old value; the new one arrives in the next render.

Why bother, when useState exists

useState is a reducer underneath. Reach for the explicit one when one of these is true:

  • Several fields move together. Starting a request sets loading, clears error and clears data — three setters that must never be out of step.
  • The next state depends on the current one in more than a trivial way.
  • The same transition happens from several places. The logic lives in one function instead of being duplicated in four handlers.
  • You want to test the logic. A reducer is a plain function — call it with a state and an action and assert on the result. No component, no render.
SituationChoose
A toggle, an input's text, one counteruseState
Two or three unrelated valuesSeparate useState calls
A request's status, data and erroruseReducer
A multi-step form or wizarduseReducer
Logic a teammate must be able to unit testuseReducer

The example that makes the case

// with useState: four setters, and every caller must remember all of them
setLoading(true); setError(null); setData(null);

// with a reducer: one dispatch, and the transition is defined once
dispatch({ type: "fetch/start" });

function reducer(state, action) {
  switch (action.type) {
    case "fetch/start":   return { status: "loading", data: null, error: null };
    case "fetch/success": return { status: "ready", data: action.data, error: null };
    case "fetch/failure": return { status: "error", data: null, error: action.error };
    default: return state;
  }
}

Notice what became impossible: there is no way to be loading and hold an error at the same time, because no transition produces that. With four independent booleans, every impossible combination is one forgotten setter away.

Make impossible states impossible

Four booleans give sixteen combinations, of which perhaps four are valid. One status field with four values gives exactly four. This is the strongest argument for reducers and it has nothing to do with performance — it is about the states your UI can never accidentally reach.

Naming actions after events, not setters

dispatch({ type: "setCount", value: 5 });         // ✗ a setter in disguise
dispatch({ type: "incremented" });                // ✓ what happened
dispatch({ type: "quantityChanged", id, qty });   // ✓

Actions describe events, not assignments. That way one action can drive several changes, and reading the list of action types tells you what the feature does — it becomes the documentation of the component's behaviour.

Why purity is enforced, not suggested

case "added":
  state.items.push(action.item);    // ✗ mutates the current state
  return { ...state };

In development, Strict Mode calls your reducer twice for each dispatch to flush out exactly this. The first call pushes the item into the existing array, the second pushes it again, and the list shows it twice. It looks like a React bug; it is a mutation that production would have hidden until something compared old and new state. Build the new array instead: items: [...state.items, action.item].

Where the side effects go

async function handleSave() {
  dispatch({ type: "save/start" });
  try {
    const saved = await api.save(state.draft);
    dispatch({ type: "save/success", saved });
  } catch (error) {
    dispatch({ type: "save/failure", error });
  }
}

A reducer cannot await, so the async work happens in the event handler and the reducer only records what happened at each step. The reducer stays pure and testable; the handler owns the request. That split is the same one Redux thunks formalise.

Testing it

test("start clears a previous error", () => {
  const before = { status: "error", data: null, error: "timeout" };
  const after = reducer(before, { type: "fetch/start" });
  expect(after).toEqual({ status: "loading", data: null, error: null });
});

No render, no mocks, no waiting. Each transition is one line of input and one of expected output, which makes it cheap to cover the awkward cases — a success arriving after a reset, a removal of an id that is not there.

Lazy initialisation

function init(items) {
  return { items, selected: null, filter: "" };
}

const [state, dispatch] = useReducer(reducer, initialItems, init);

The third argument runs once with the second as its input. Useful for expensive setup, and for reusing the same function to handle a reset action: case "reset": return init(action.items);

dispatch is stable, and that matters

React guarantees the same dispatch function for the life of the component. So it never needs to be in a dependency array, it never breaks a memo, and it can go into context without a useCallback — which is why the state-and-dispatch context split in the previous chapter works so cleanly.

Seeing every action while debugging

function withLogging(reducer) {
  return (state, action) => {
    const next = reducer(state, action);
    console.log(action.type, { state, next });
    return next;
  };
}

const [state, dispatch] = useReducer(withLogging(reducer), initial);

Because a reducer is just a function, wrapping one gives you middleware for free. Remove it before shipping: logging is a side effect, and Strict Mode's double call will print every action twice in development.

Reducer plus context: the poor man's store

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

This covers a surprising amount of ground before you need a real library: centralised logic, a stable dispatch, and components that only read what they need. What it does not give you is selectors — every consumer of TasksContext re-renders on every change — and that is the line where a store starts earning its dependency.

Keeping it immutable

case "toggle":
  return {
    ...state,
    items: state.items.map((i) =>
      i.id === action.id ? { ...i, done: !i.done } : i
    ),
  };

The same rules as useState: return a new object, spread at every level you change. If the nesting makes this painful, either flatten the state or use Immer's produce, which lets you write mutations and produces an immutable result. Returning the same state object is also meaningful: React sees nothing changed and can skip re-rendering.

With TypeScript, type the reducer's parameters and let React infer the rest: useReducer(reducer, initial), no type arguments. See TypeScript with React.

Interview answer, one sentence

"A reducer moves state transitions out of the handlers and into one pure function, so related fields always change together and impossible combinations stop being reachable. It is also testable without rendering, and dispatch is stable, which makes it ideal to put in context; the async work stays in the handler, which dispatches what happened."

04Practice this chapter

Opens in the editor: write it, run it, and check it against real tests.

  • 11 testsA todos reducerintermediate
  • 9 testsA discriminated-union reducerintermediate
  • 10 testsA shopping cart with useReducerintermediate
  • 6 testsUndo and redo, as one reducerintermediate
All 66 for this level →
I3

Finished reading?

0 of 57 chapters read in this series

→Next stop

  1. I2Previous · I2Context API
  2. I3you are here
  3. Next · I4Custom hooksSharing stateful logic between components — not sharing the state itself.3 min read

01On this page

12 sections · 0%

  1. 01The shape
  2. 02Why bother, when useState exists
  3. 03The example that makes the case
  4. 04Naming actions after events, not setters
  5. 05Why purity is enforced, not suggested
  6. 06Where the side effects go
  7. 07Testing it
  8. 08Lazy initialisation
  9. 09dispatch is stable, and that matters
  10. 10Seeing every action while debugging
  11. 11Reducer plus context: the poor man's store
  12. 12Keeping it immutable

[] chapters · t top