Reducers with useReducer

React · Practical · 12 min

When many event handlers update related state, a reducer makes the logic testable and easier to follow.

As a component grows, state updates get scattered across many handlers. A reducer gathers them into one pure function:

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

const [tasks, dispatch] = useReducer(reducer, initialTasks);
dispatch({ type: "added", id: 3, text: "Write docs" });

Event handlers now describe what happened (dispatch({ type: "deleted", id })) and the reducer decides how state changes. The reducer receives the current state and the action and returns the next state.

Rules for reducers:

  • They must be pure: no requests, timers or mutations; return new objects instead of modifying state. (Strict Mode calls them twice to check.)
  • Each action describes one user interaction, even if it changes several fields. "reset_form" beats five "set_field" actions.
  • Name actions in the past tense or as events ("added", "item_selected").

When to choose useReducer over useState: several pieces of state change together, the next state depends on the previous in non-trivial ways, or you want to unit-test the logic in isolation (a reducer is just a function). For a boolean or a single input, useState is simpler.

dispatch has a stable identity, so it's safe to pass deep down or put in context; combining a reducer with context is a lightweight app-state pattern. A third argument, init, computes the initial state lazily: useReducer(reducer, arg, init).

Example

import { useReducer } from "react";

function cartReducer(state, action) {
  switch (action.type) {
    case "added": {
      const existing = state.items.find(i => i.id === action.item.id);
      const items = existing
        ? state.items.map(i => (i.id === action.item.id ? { ...i, qty: i.qty + 1 } : i))
        : [...state.items, { ...action.item, qty: 1 }];
      return { ...state, items };
    }
    case "removed":
      return { ...state, items: state.items.filter(i => i.id !== action.id) };
    case "coupon_applied":
      return { ...state, discount: action.percent };
    default:
      throw new Error(`Unknown action ${action.type}`);
  }
}

export default function Cart() {
  const [cart, dispatch] = useReducer(cartReducer, { items: [], discount: 0 });
  const total = cart.items.reduce((s, i) => s + i.price * i.qty, 0) * (1 - cart.discount / 100);
  return (
    <>
      <button onClick={() => dispatch({ type: "added", item: { id: 1, name: "Pen", price: 2 } })}>Add pen</button>
      <button onClick={() => dispatch({ type: "coupon_applied", percent: 10 })}>SAVE10</button>
      <p>{cart.items.length} lines, total {total.toFixed(2)}</p>
    </>
  );
}

Remember

  • const [state, dispatch] = useReducer(reducer, initial); handlers dispatch actions
  • Reducers are pure: (state, action) => newState, never mutating
  • Prefer a reducer when related state changes together or logic needs testing

Watch out for

  • Doing side effects (fetching, saving) inside the reducer instead of in the event handler.

Check yourself

  1. Where should an API call that saves the cart go when using useReducer?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: react.dev/learn/extracting-state-logic-into-a-reducer