useReducer

Move the rules out of the handlers and into one function you can read, test and trust.

15of 20
≈ 11Min read
7Parts

The shape

dispatch is stable forever — it never changes identity, so it can be passed to a memoised child or left out of a dependency array without a thought.

a function of (state, action) → state
function reducer(state, action) {
  switch (action.type) {
    case "add":    return { ...state, items: [...state.items, action.door] };
    case "remove": return { ...state, items: state.items.filter((d) => d.id !== action.id) };
    case "reset":  return INITIAL;
    default:       return state;
  }
}

const [state, dispatch] = React.useReducer(reducer, INITIAL);

<button onClick={() => dispatch({ type: "add", door })}>Add</button>

Why it beats several useState calls

  • The next state depends on the previous one in more than a trivial way.
  • Several fields change together — a successful load sets the data, clears the error and changes the status.
  • The same transition happens from several places — a keyboard shortcut, a button and a drop target all mean "add".
  • You want to read the rules without chasing them through eight handlers.
the same feature, twice
// four setters, and the rules live in every handler that touches them
const [items, setItems] = useState([]);
const [express, setExpress] = useState(false);
const [error, setError] = useState(null);
const [status, setStatus] = useState("idle");

// one dispatch, and the rules live in one place
dispatch({ type: "add", door });

A reducer must be pure

Because it is pure, it can be called in a test with no React at all: give it a state and an action, and assert on what comes back. That is the biggest practical reason to use one.

the three rules
function reducer(state, action) {
  state.items.push(action.door);     // NO: mutating the state
  fetch("/api/save", …);             // NO: side effects belong in handlers or effects
  return { ...state, at: Date.now() };  // NO: not deterministic

  // yes: a new object, computed only from state and action
  return { ...state, items: [...state.items, action.door] };
}

Naming actions

When actions name events, the reducer becomes a readable list of the rules of your feature — and a new rule is a new case, not a change in five components.

name the event, not the setter
// describes the mechanism — you end up with a setter with extra steps
dispatch({ type: "setItems", items: [...items, door] });

// describes what happened — the reducer decides what that means
dispatch({ type: "added", door });
dispatch({ type: "removed", id });
dispatch({ type: "express_toggled" });

Rules live in the reducer

the same rule, enforced once
case "added": {
  if (state.items.some((d) => d.id === action.door.id)) return state;   // no duplicates
  if (state.items.length >= 5) return { ...state, error: "Five is the maximum" };
  return { ...state, error: null, items: [...state.items, action.door] };
}
Returning state unchanged

Returning the same object tells React nothing happened, and it skips the re-render. That is how a reducer rejects an action cleanly.

Lazy initialisation

the third argument
function init(doors) {
  return { items: doors, express: false, error: null };
}

const [state, dispatch] = React.useReducer(reducer, doors, init);

// and reset becomes trivial
case "reset": return init(action.doors);

Reducer or a library

  • One feature, one component tree: useReducer is plenty.
  • Shared across the app: a reducer plus context, or a small store like Zustand.
  • Server data: neither — that is a cache, and belongs in TanStack Query.
  • Redux today is mostly Redux Toolkit, and is the same idea with devtools, middleware and a single store.

Practice