useReducer
Reducer (state, action)=>newState centralizes complex state transitions; pair with Context for app-wide state.
useReducer moves state-update logic out of the component into a pure reducer function. Prefer it over useState when updates are complex, interrelated, or follow explicit transitions.
Anatomy#
const initialState = { count: 0, step: 1 };
function reducer(state, action) { // pure: (state, action) => newState
switch (action.type) {
case "increment": return { ...state, count: state.count + state.step };
case "decrement": return { ...state, count: state.count - state.step };
case "setStep": return { ...state, step: action.payload };
case "reset": return initialState;
default: throw new Error(`Unknown action: ${action.type}`);
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<>
<span>{state.count}</span>
<button onClick={() => dispatch({ type: "increment" })}>+</button>
<button onClick={() => dispatch({ type: "reset" })}>reset</button>
</>
);
}
🎯 The reducer must be pure: no mutations, no side effects, no async. Given the same (state, action) it returns the same next state. Side effects belong in event handlers or effects, not the reducer.
Dispatch flow#
flowchart LR
UI["Component"] -->|"dispatch(action)"| R["reducer(state, action)"]
R -->|"returns new state"| RE["React re-renders"]
RE --> UI
Lazy initialization#
Pass a third init function to compute initial state lazily (e.g. reading localStorage once) instead of on every render:
const [state, dispatch] = useReducer(reducer, initialArg, (arg) => ({
count: Number(localStorage.getItem("count")) || arg.count,
}));
useReducer vs useState 🎯#
Use useState when |
Use useReducer when |
|---|---|
| Independent primitive/simple values | Multiple fields that change together |
| Few, simple updates | Many action types / explicit transitions |
| Next state doesn't depend on complex prior state | Next state derived from prior state + action |
| — | You want to test update logic in isolation (reducer is pure) |
| — | You pass updates deep (dispatch identity is stable) |
🟢 dispatch has a stable identity across renders—React guarantees it never changes. You can pass it down through context or props without useCallback and without breaking memo.
useReducer + Context = app-wide state#
Combine a reducer for logic with context for distribution. Split state and dispatch into two contexts so dispatch-only components don't re-render on state changes.
const StateCtx = createContext(null);
const DispatchCtx = createContext(null);
export function StoreProvider({ children }) {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<StateCtx.Provider value={state}>
<DispatchCtx.Provider value={dispatch}>{children}</DispatchCtx.Provider>
</StateCtx.Provider>
);
}
export const useStore = () => useContext(StateCtx);
export const useDispatch = () => useContext(DispatchCtx);
This is essentially a mini-Redux with zero dependencies—good for medium apps that don't need selectors or middleware.
Immutable updates ⚠️#
Never mutate state; always return a new object/array. Mutation skips re-renders (React compares by reference) and breaks time-travel/undo.
// ⚠️ mutation — same reference, React may skip render
case "add": state.items.push(action.item); return state;
// 🟢 new references at every changed level
case "add": return { ...state, items: [...state.items, action.item] };
For deeply nested state, reach for Immer's produce to write "mutating" code that stays immutable.
Interview Q&A#
Q1. What signature does a reducer have and what constraint must it satisfy?
(state, action) => newState. It must be pure: no mutation of state, no side effects, deterministic for the same inputs.
Q2. When do you prefer useReducer over useState?
When state has multiple interrelated fields, many distinct transition types, or the next state depends on the previous state in non-trivial ways—centralizing logic in one testable reducer.
Q3. Why can you pass dispatch down without useCallback?
React guarantees dispatch has a stable identity for the lifetime of the component, so it never triggers dependency-array changes or breaks memoized children.
Q4. How do you build app-wide state with just React?
Combine useReducer (logic) with Context (distribution), typically splitting state and dispatch into separate contexts so dispatch-only consumers avoid re-rendering on state updates.
Q5. What breaks if a reducer mutates state instead of returning a new object? React compares state by reference; a mutation returns the same reference, so components may not re-render, and features like undo/redo and consistent snapshots break.