Capítulo 31 de 116

Chapter 31: Extracting State Logic into a Reducer

Core Idea

When a component's event handlers each update state in several places with related logic, consolidating all that logic into one reducer function outside the component — dispatching named "actions" instead of calling setters directly — makes the component's handlers shorter and the state transitions easier to test and reason about together.

Key Concepts

  • Migration path: (1) move from setState calls to dispatch(action) calls in event handlers, (2) write a reducer function that takes (state, action) and returns the next state, (3) replace useState with useReducer(reducer, initialState) in the component.
  • Actions are plain objects describing "what happened" ({ type: 'added', text }), not "what should change" — the reducer, not the caller, decides how the state actually updates for a given action. This separates what the user did from how state responds.
  • The reducer itself must stay pure (same rule as component rendering, Ch 18): given the same (state, action) pair, it must return the same next state, with no side effects — this is what makes reducers easy to unit test in isolation, without rendering any component.
  • useReducer vs. useState: useReducer trades slightly more setup (writing the reducer function, action shapes) for centralizing update logic in one place instead of scattering it across every event handler — most valuable once a component has several related state updates or a handler that updates multiple pieces of state together.
  • Writing reducers well: keep each case in the reducer's switch focused and avoid deeply nested conditionals; when the update logic against nested state gets complex, Immer works with useReducer the same way it does with useState (Ch 25) to let case bodies read like direct mutation while staying safe.

Code Examples

function tasksReducer(tasks, action) {
  switch (action.type) {
    case 'added':
      return [...tasks, { id: action.id, text: action.text, done: false }];
    case 'deleted':
      return tasks.filter(t => t.id !== action.id);
    default:
      throw Error('Unknown action: ' + action.type);
  }
}

const [tasks, dispatch] = useReducer(tasksReducer, []);
  • What it demonstrates: the reducer pattern's shape — one pure function centralizing every state transition, dispatched to by name rather than called with a computed next value.

Key Takeaways

  1. Reach for useReducer when a component's state updates are numerous, related, or scattered across handlers — not as a default replacement for simple, independent useState calls.
  2. A reducer's default case should throw (or otherwise loudly fail) on an unrecognized action type — silent no-ops hide bugs.
  3. Reducers are trivially unit-testable in isolation (expect(reducer(state, action)).toEqual(nextState)) precisely because they're pure functions with no component or DOM involved.

Connects To

  • Ch 18 (Keeping Components Pure): the same purity requirement applied to reducer functions.
  • Ch 61 (useReducer): the API reference for the Hook this chapter's pattern is built on.
  • Ch 33 (Scaling Up with Reducer and Context): combining this pattern with Context to avoid prop-drilling the reducer's state/dispatch.