useReducer for Complex State
When useReducer beats useState — action tables, pure reducers, dispatch stability, context pairing, and migration patterns.
- 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
- Side effects inside the reducer — breaks purity and concurrent rendering assumptions.
- Mutating
statein place — React may skip re-render if identity doesn’t change. - Giant universal reducer for the whole app without boundaries — modules and features suffer.
- Stringly-typed actions without conventions — typos become silent no-ops if you
default: return state. Prefer throw on unknown in app reducers. - Recreating initial state every render as a second argument without lazy init when the object is expensive — use
initor hoist constants. - 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.
Related on this site
- Props vs State — ownership
- Context API Basics — dispatch/state split
- useEffect Fundamentals — async orchestration
- Fetch API Fundamentals — request outcomes
- Custom Hooks Design — wrap reducer machines
- Batching State Updates — how updates flush
Further reading
Related guides
- Context API BasicsReact context for dependency injection — createContext, Provider, useContext, default values, and when context is the wrong tool.
- Custom Hooks DesignExtract reusable stateful logic with use* hooks — API design, composition, testing seams, and footguns that recreate render bugs.
- Props vs StateProps are inputs from the parent; state is data the component owns over time. How to choose, lift, and avoid the usual traps.
- Rules of HooksWhy hooks must be top-level and only in React functions — call order, the linter, and the bugs that break when you cheat.
- useEffect FundamentalsWhen useEffect runs, how cleanup works, what belongs in the dependency array, and the bugs from treating it like componentDidMount.