React

useReducer for Complex State

Learn when and how to use useReducer in React to manage state that has multiple related values or transitions that depend on each other.

useState gets you surprisingly far. But there is a point where the state in a component starts fighting you. You have five useState calls, the update logic is spread across a dozen handlers, and when something breaks you spend ten minutes tracing which setter fired in what order.

That is usually the moment to reach for useReducer.

What useReducer Actually Does

useReducer is just useState with a different interface. Instead of calling a setter directly with a new value, you dispatch an action and a separate function (the reducer) decides what the new state should be.

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

The reducer is a pure function with this signature:

function reducer(state: State, action: Action): State {
  // return the new state based on action.type
}

That is it. No magic. The value here is that all state transition logic lives in one place and the component just describes what happened, not how state should change.

A Real Example: Form With Multiple Fields

Here is where I first found useReducer genuinely useful. A controlled form with several fields, some validation state, and a loading flag:

type FormState = {
  name: string;
  email: string;
  error: string | null;
  loading: boolean;
};

type FormAction =
  | { type: "set_name"; value: string }
  | { type: "set_email"; value: string }
  | { type: "submit_start" }
  | { type: "submit_success" }
  | { type: "submit_error"; message: string };

const initialState: FormState = {
  name: "",
  email: "",
  error: null,
  loading: false,
};

function formReducer(state: FormState, action: FormAction): FormState {
  switch (action.type) {
    case "set_name":
      return { ...state, name: action.value };
    case "set_email":
      return { ...state, email: action.value };
    case "submit_start":
      return { ...state, loading: true, error: null };
    case "submit_success":
      return { ...state, loading: false };
    case "submit_error":
      return { ...state, loading: false, error: action.message };
    default:
      return state;
  }
}

The component becomes much cleaner:

function ContactForm() {
  const [state, dispatch] = useReducer(formReducer, initialState);

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    dispatch({ type: "submit_start" });

    try {
      await submitForm({ name: state.name, email: state.email });
      dispatch({ type: "submit_success" });
    } catch (err) {
      dispatch({ type: "submit_error", message: "Something went wrong." });
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={state.name}
        onChange={(e) => dispatch({ type: "set_name", value: e.target.value })}
      />
      <input
        value={state.email}
        onChange={(e) => dispatch({ type: "set_email", value: e.target.value })}
      />
      {state.error && <p>{state.error}</p>}
      <button disabled={state.loading}>Submit</button>
    </form>
  );
}

Every state change now has a name. When you read dispatch({ type: "submit_start" }) you know exactly what is happening without reading the reducer. That explicitness pays off when you come back to the code three months later.

Transitions That Depend on Current State

The other place useReducer shines is when the next state depends on the current state in non-trivial ways.

With useState, this often turns into something awkward:

// spread the existing state, flip a flag, and also clear an error
setUser((prev) => ({ ...prev, isActive: !prev.isActive, error: null }));

With useReducer, you describe the action once and handle the full transition in the reducer:

case "toggle_active":
  return {
    ...state,
    isActive: !state.isActive,
    error: null,
    lastModified: new Date().toISOString(),
  };

The reducer sees the full current state. It can access any piece of it without you needing to thread it through closures.

TypeScript and Discriminated Unions

The TypeScript story for useReducer is genuinely good. Discriminated unions on the action type give you exhaustive checking:

type CounterAction =
  | { type: "increment" }
  | { type: "decrement" }
  | { type: "reset"; to: number };

If you add a new case to the union and forget to handle it in the reducer, TypeScript catches it. You can also extract action creator functions if you want cleaner dispatch calls:

const actions = {
  increment: (): CounterAction => ({ type: "increment" }),
  reset: (to: number): CounterAction => ({ type: "reset", to }),
};

dispatch(actions.increment());

When Not to Use It

useReducer adds a layer of indirection. For simple state, that cost is not worth it:

// This does not need useReducer
const [isOpen, setIsOpen] = useState(false);

A good signal: if you can describe the state change in the setter without reading the current state and the logic fits in one line, stay with useState. If you have more than two or three related state values that update together, or if you want to be able to test state transitions in isolation, useReducer is worth it.

Testing is one concrete advantage. The reducer is a pure function — you can unit test every transition without rendering anything:

it("clears error on submit start", () => {
  const state = { ...initialState, error: "previous error" };
  const result = formReducer(state, { type: "submit_start" });
  expect(result.error).toBeNull();
  expect(result.loading).toBe(true);
});

The Takeaway

useReducer is not a big architectural decision. It is just a different way to update state, one that works better when the transitions are complex or interrelated. The pattern of "dispatch an action, let the reducer decide" keeps components focused on what the user did rather than how the state should change as a result. Once I started thinking in terms of actions and transitions rather than setters and values, the component logic got a lot easier to follow.

← Older
useState and the Mental Model of State
Newer →
useEffect Mistakes I Stopped Making

Newsletter

A weekly newsletter on React, Next.js, AI-assisted development, and engineering. No spam, unsubscribe any time.