Back to React
React
medium
mid

How do you manage state in React using useState and useReducer?

useState fits 1–3 independent values updated with simple setters. useReducer fits state with multiple related fields or complex transitions — pull the update logic into a pure reducer (state, action) → state. Reducers are easier to test, easier to log, and naturally handle multi-step state machines. Reach for useReducer when setState calls start to multiply or several pieces of state must change together.

7 min read·~12 min to think through

Same problem from two angles.

useState — small, independent values

tsx
const [count, setCount] = useState(0);
const [name, setName] = useState('');

setCount(c => c + 1);                     // functional updater
setName(e.target.value);

Best when:

  • State is 1–3 independent values.
  • Updates are simple (setX(newValue) or setX(prev => ...)).
  • The fields don't constrain each other.

useReducer — state machines & related fields

tsx
type State = { items: Todo[]; filter: 'all' | 'done' | 'todo' };
type Action =
  | { type: 'add'; text: string }
  | { type: 'toggle'; id: string }
  | { type: 'setFilter'; filter: State['filter'] };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'add':
      return { ...state, items: [...state.items, { id: uid(), text: action.text, done: false }] };
    case 'toggle':
      return {
        ...state,
        items: state.items.map(t => t.id === action.id ? { ...t, done: !t.done } : t),
      };
    case 'setFilter':
      return { ...state, filter: action.filter };
  }
}

const [state, dispatch] = useReducer(reducer, { items: [], filter: 'all' });

dispatch({ type: 'add', text: 'Buy milk' });

When to switch from useState → useReducer

  • Multiple setState calls in one handler ('set this, then this, then this').
  • State fields that must move together (loading + error + data triplet).
  • A clear state machine: idle → loading → success / error.
  • You want to log/replay actions for debugging.
  • Update logic is non-trivial enough that you'd want to unit-test it.

Hybrid: lazy init + reducer

tsx
const [state, dispatch] = useReducer(reducer, initialArg, init);

init(initialArg) runs once on mount — handy for hydrating from localStorage.

Reducer + Context for app state

tsx
const StateCtx = createContext<State | null>(null);
const DispatchCtx = createContext<React.Dispatch<Action> | null>(null);

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

Split contexts so consumers of only dispatch don't re-render when state changes.

Pitfalls

  • Mutating state inside a reducer (state.items.push(...)) — React won't detect the change.
  • Putting non-serializable values (promises, class instances) in state — breaks DevTools/replay.
  • Using useReducer for state that's really server state — reach for React Query instead.

Server state ≠ client state

If your reducer mostly handles loading/error/data from a fetch, you're rebuilding React Query. Use that instead, and keep useReducer for UI/wizard/form state.

Follow-up questions

  • How do useReducer and Redux differ?
  • When should reducer + context replace a state library?
  • Why must reducers be pure?

Common mistakes

  • Mutating draft state directly without Immer — React doesn't detect the change.
  • Reducer dispatched from inside the reducer — infinite loop.
  • Modeling server state with useReducer when React Query/SWR fits better.

Performance considerations

  • useReducer doesn't change React's render cost — it changes how you write the updates. For very hot updates, useReducer can win slightly because there's a single state object instead of N useState slots, and React can batch one big update.

Edge cases

  • If the reducer is heavy, dispatching inside an effect can cascade rapidly.
  • Context + reducer re-renders every consumer on every state change — split contexts.
  • StrictMode dev double-invokes reducers; they must be pure.

Real-world examples

  • Multi-step forms, drag-and-drop boards (Trello-like), wizards, complex filters, the React DevTools 'Profiler' tab itself. Redux is essentially useReducer + middleware + global store.

Senior engineer discussion

Senior signal: knowing when to climb the ladder — useState → useReducer → useReducer+Context → external store (Zustand/Redux). Climb only when the current rung is too noisy; don't pre-architect with Redux for two booleans.

Related questions