Back to React
React
medium
mid

What is the difference between useState and useReducer, and when do you use each?

useState is direct value-replacement, ideal for independent primitives or small objects. useReducer centralizes complex transitions in a pure function, ideal when next-state depends on the action *and* current state in non-trivial ways.

5 min read·~10 min to think through

Both hooks store state inside the same React fiber slot. The difference is the API shape and the discipline it encourages:

  • useState[value, setValue]. Cheap, direct, and the right default. Use for booleans, strings, numbers, or small objects whose updates don't depend on each other.
  • useReducer[state, dispatch]. Forces you to express transitions as (state, action) => state. The pure-function shape makes complex multi-field updates testable and debuggable.

Reach for useReducer when:

  1. Several pieces of state always update together (e.g. { status, data, error }).
  2. Next state depends on current state and a non-trivial action (wizard steps, undo/redo).
  3. You want to pass a stable dispatch reference deep into a tree without prop-drilling setX callbacks (dispatch is referentially stable).

Both can be lazily initialized — useState(() => expensive()) and useReducer(reducer, initialArg, init).

A pragmatic test: if you find yourself writing setX(prev => ({ ...prev, ... })) and reaching across multiple fields, you've outgrown useState and reducer will read better.

Code

tsx
// useState — fine for one-off
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<Error | null>(null);
const [loading, setLoading] = useState(false);

// useReducer — clearer when transitions are coupled
type Action =
  | { type: "start" }
  | { type: "ok"; data: T }
  | { type: "err"; error: Error };

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

function reducer(s, a: Action) {
  switch (a.type) {
    case "start": return { status: "loading", data: null, error: null };
    case "ok":    return { status: "success", data: a.data, error: null };
    case "err":   return { status: "error",   data: null,   error: a.error };
  }
}
Same fetch state, two styles

Follow-up questions

  • How do you lazily initialize state with useReducer?
  • Why is dispatch referentially stable across renders?
  • When would you graduate from useReducer to a state library (Zustand, Redux)?

Common mistakes

  • Mutating state inside the reducer — must return a new object.
  • Putting derived values in state instead of computing them in render.
  • Using useReducer for a single boolean — overkill.

Performance considerations

  • Both bail out of re-render when the new value is `===` to the old.
  • Dispatch is stable, so passing it through context doesn't bust child memoization.

Edge cases

  • An action object created inline inside render is fine — it's the dispatched value, not a dep.
  • useReducer with a non-pure reducer breaks Strict Mode double-invoke detection.

Real-world examples

  • Form-state managers (react-hook-form internals, formik) use reducer-like patterns under the hood for predictable transitions.

Senior engineer discussion

Senior signal: discuss when reducer + context becomes a state-management framework, the cost of context for high-churn state, and when to switch to an external store with selector subscriptions.

Related questions