AniUI Academy

useReducer for Complex State

When a handful of related setState calls stop being manageable, and a single dispatch-and-reducer pattern makes state transitions easier to read, test, and reason about.

9 min read

useState handles most components fine. Occasionally state grows into several related pieces that change together, in response to the same events, and tracking that with separate useState calls starts to blur together the logic of what happened and how state should react to it. useReducer separates those two concerns.

The shape

import { useReducer } from "react";
 
function reducer(state, action) {
  switch (action.type) {
    case "increment":
      return { count: state.count + action.amount };
    case "decrement":
      return { count: state.count - action.amount };
    case "reset":
      return { count: 0 };
    default:
      throw new Error(`Unknown action: ${action.type}`);
  }
}
 
function Counter() {
  const [state, dispatch] = useReducer(reducer, { count: 0 });
 
  return (
    <div>
      <p>Count: {state.count}</p>
      <button onClick={() => dispatch({ type: "increment", amount: 1 })}>+1</button>
      <button onClick={() => dispatch({ type: "decrement", amount: 1 })}>-1</button>
      <button onClick={() => dispatch({ type: "reset" })}>Reset</button>
    </div>
  );
}

dispatch doesn't set state directly — it sends an action, a plain object describing what happened. The reducer function is the single place that interprets actions and computes the next state. This is exactly the same idea as an event listener versus its handler: dispatch says "this event occurred," and the reducer decides what that means for state.

Why a reducer must be pure

A reducer takes (state, action) and returns a new state — nothing else. No fetching, no setTimeout, no mutating the state argument. Purity is what makes this pattern worth the extra structure: given the same state and the same action, a reducer always produces the same result, which makes it trivial to test in isolation (just call it directly with sample inputs and check the output) and easy to reason about without running the whole component.

// A reducer is just a function — testable with no React involved at all
test("increment adds the given amount", () => {
  expect(reducer({ count: 0 }, { type: "increment", amount: 5 })).toEqual({ count: 5 });
});

When it earns its complexity

useReducer isn't a strict upgrade over useState — for a single value, or a couple of independent ones, separate useState calls are simpler and more direct. It earns its place specifically when:

  • Several pieces of state change together in response to the same event.
  • The next state genuinely depends on the previous state in a non-trivial way (not just "add one").
  • The same kind of update happens from several different places, and you want one reviewable place that defines what each update actually does.

A form with interdependent fields is a common realistic case — selecting a country might need to reset the selected state/province, for instance:

function formReducer(state, action) {
  switch (action.type) {
    case "setCountry":
      return { ...state, country: action.value, region: "" }; // reset region
    case "setRegion":
      return { ...state, region: action.value };
    default:
      return state;
  }
}

Expressing "changing the country also resets the region" as a useState pair would mean remembering to call setRegion("") at every single call site that changes the country. In the reducer, that rule lives in exactly one place and can't be forgotten by a future call site.

Try it yourself

Try it yourself
Loading playground...

What to remember

  • useReducer returns [state, dispatch]; dispatch sends a plain action object describing what happened, and a pure reducer function computes the next state from it.
  • A reducer must be pure — no side effects — which is exactly what makes it easy to test and predict in isolation.
  • Reach for useReducer when several pieces of state change together, or when the same kind of transition happens from multiple places and deserves one single, reviewable definition.
  • For simple, independent values, plain useState calls remain simpler — useReducer is a tool for complexity, not a default.

Check yourself

4 questions · pass 3/4 to unlock Custom Hooks

up to 50
  1. 1.What are the two things you get back from useReducer(reducer, initialState)?

  2. 2.What must a reducer function be, and why does that matter?

  3. 3.Why might useReducer be preferred over several separate useState calls for something like a multi-step form with several interdependent fields?

  4. 4.In dispatch({ type: "increment", amount: 5 }), what is the object being passed usually called?

4 left to answer