ESC

Type to search the knowledge base.

useReducer for Complex State

When useReducer beats useState — action tables, pure reducers, dispatch stability, context pairing, and migration patterns.

intermediate6 min read
  • react
  • usereducer
  • state
  • hooks

useState is enough for independent bits of UI state. When the next state depends on the previous in multiple coordinated ways — multi-step forms, request machines, undo stacks — a reducer keeps transitions explicit:

const [state, dispatch] = useReducer(reducer, initialArg, init?);
dispatch({ type: 'ACTION', /* payload */ });

useReducer is React’s hook form of (state, action) => nextState. Same idea as Redux reducers, without requiring a global store.

Docs: useReducer — react.dev.

The problem with sprawling useState

function Checkout() {
  const [step, setStep] = useState(0);
  const [address, setAddress] = useState(null);
  const [shipping, setShipping] = useState('standard');
  const [error, setError] = useState(null);
  const [submitting, setSubmitting] = useState(false);
  // half a dozen setX calls that must stay consistent…
}

Bugs show up as illegal combinations: submitting true on step 0, error left over after success, two fields updated out of order in async handlers. A reducer forces you to name transitions and update related fields together.

Model

function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    case 'set':
      return { count: action.payload };
    default:
      throw new Error(`Unknown action: ${action.type}`);
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, { count: 0 });
  return (
    <>
      <p>{state.count}</p>
      <button type="button" onClick={() => dispatch({ type: 'increment' })}>
        +
      </button>
    </>
  );
}
Piece Role
state Current snapshot
dispatch(action) Queue an update; identity of dispatch is stable
reducer Pure function: old state + action → new state
init Lazy initializer: useReducer(reducer, arg, init) runs init(arg) once

Reducers must be pure with respect to props/state rules for concurrent rendering — no fetch, no DOM writes inside the reducer. Side effects belong in effects or in the event handler after/before dispatch depending on design.

Lazy initialization

function init(userId) {
  return { userId, draft: loadDraft(userId), status: 'idle' };
}

const [state, dispatch] = useReducer(reducer, props.userId, init);

Use when initial state is expensive or depends on props only for the first mount. To reset when userId changes, remount with key={userId} (Keys in Lists) or dispatch a reset action.

Complex example: request status machine

const initial = { status: 'idle', data: null, error: null };

function fetchReducer(state, action) {
  switch (action.type) {
    case 'fetch/start':
      return { status: 'loading', data: state.data, error: null };
    case 'fetch/success':
      return { status: 'success', data: action.payload, error: null };
    case 'fetch/error':
      return { status: 'error', data: null, error: action.payload };
    case 'fetch/reset':
      return initial;
    default:
      return state;
  }
}

function useFetchResource(url) {
  const [state, dispatch] = useReducer(fetchReducer, initial);

  useEffect(() => {
    const controller = new AbortController();
    dispatch({ type: 'fetch/start' });

    fetch(url, { signal: controller.signal })
      .then(async (res) => {
        if (!res.ok) throw new Error(String(res.status));
        return res.json();
      })
      .then((data) => dispatch({ type: 'fetch/success', payload: data }))
      .catch((err) => {
        if (err.name === 'AbortError') return;
        dispatch({ type: 'fetch/error', payload: err });
      });

    return () => controller.abort();
  }, [url]);

  return state;
}

Illegal states like status: 'success' with a non-null error are harder to produce — every action sets a full consistent slice.

Why dispatch stability matters

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

useEffect(() => {
  // safe: dispatch identity does not change
  dispatch({ type: 'hydrate', payload: readCache() });
}, [dispatch]); // empty deps is also fine — dispatch is stable

Unlike a setState wrapper you might recreate, React guarantees dispatch is stable. That makes it ideal to pass through context without memo gymnastics.

Pairing with context

const StateCtx = createContext(null);
const DispatchCtx = createContext(null);

function Provider({ children }) {
  const [state, dispatch] = useReducer(reducer, initial);
  return (
    <StateCtx.Provider value={state}>
      <DispatchCtx.Provider value={dispatch}>{children}</DispatchCtx.Provider>
    </StateCtx.Provider>
  );
}

function useBoardDispatch() {
  return useContext(DispatchCtx);
}

Components that only dispatch (toolbar buttons) skip re-renders when state changes if they don’t read StateCtx. Pattern from Scaling with Reducer and Context.

useState vs useReducer — decision guide

Prefer useState Prefer useReducer
One or two independent values Many fields update together
Updates are simple sets Transitions have names and rules
Local widget Testable transition table
Rare illegal-state risk Need auditability / logging of actions

They are interchangeable in power — useState is implemented with reducers under the hood. Choose the API that makes invalid states harder and intent clearer.

Immer and mutable-looking updates

Reducers should return new state objects for changed slices:

case 'todo/toggle':
  return {
    ...state,
    todos: state.todos.map((t) =>
      t.id === action.id ? { ...t, done: !t.done } : t
    ),
  };

Libraries like Immer can express this with “mutate a draft” syntax while producing immutable results. Keep purity either way.

TypeScript actions (discriminated unions)

type Action =
  | { type: 'fetch/start' }
  | { type: 'fetch/success'; payload: Item[] }
  | { type: 'fetch/error'; payload: Error };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'fetch/success':
      return { status: 'success', data: action.payload, error: null };
    // …
  }
}

Exhaustiveness checks catch missing cases when you add actions.

Footguns

  1. Side effects inside the reducer — breaks purity and concurrent rendering assumptions.
  2. Mutating state in place — React may skip re-render if identity doesn’t change.
  3. Giant universal reducer for the whole app without boundaries — modules and features suffer.
  4. Stringly-typed actions without conventions — typos become silent no-ops if you default: return state. Prefer throw on unknown in app reducers.
  5. Recreating initial state every render as a second argument without lazy init when the object is expensive — use init or hoist constants.
  6. Forgetting to handle async — reducers don’t await; orchestrate in effects/handlers, dispatch outcomes.

Interview angle

Prompt: “When do you use useReducer over useState?”

Strong answer: “When state transitions are complex or multi-field — I want a pure (state, action) => next table I can test and reason about. dispatch is stable, so it’s easy to pass via context. I still keep reducers pure and put fetching in effects, dispatching success/error actions. For a single toggle, useState is clearer.”

Whiteboard: sketch actions for a login form (edit, submit, success, failure) and show how error clears on edit.

Further reading

Related guides