What useReducer is and why it exists
useState is perfect for simple, independent values. But when a component's state has
several fields that change together — a form's values plus its loading/error/success
status, for example — you end up calling multiple setters in every handler and the logic
scatters across the component. useReducer centralizes that logic in a single pure
function: the reducer. Every state transition is a dispatch away, and the entire
update logic lives in one testable place.
Interviewers reach for useReducer questions when they want to know whether you can
model state changes explicitly rather than imperatively calling setters.
The API
const [state, dispatch] = useReducer(reducer, initialState)
reducer— a pure function(state, action) => nextState.initialState— the value for the first render.state— the current state.dispatch— sends an action to the reducer; React calls the reducer with the current state and the action, then re-renders with the returned state.
React guarantees that dispatch has a stable identity across renders — you can pass
it through context or include it in dependency arrays without causing re-renders.
The reducer function
A reducer takes the current state and an action and returns the next state. It must be pure: no side effects, no mutation of the state argument, deterministic output.
function cartReducer(state, action) {
switch (action.type) {
case 'add_item':
return { ...state, items: [...state.items, action.item] }
case 'remove_item':
return { ...state, items: state.items.filter(i => i.id !== action.id) }
case 'clear':
return { ...state, items: [] }
default:
return state // always return current state for unknown actions
}
}
Two rules worth memorizing for interviews: (1) return a new object/array, never
mutate the argument; (2) always handle default by returning state — returning
undefined causes subtle render bugs.
Action conventions
Actions are plain objects. The type field identifies what happened; any extra data is
usually called payload.
dispatch({ type: 'add_item', item: { id: 1, name: 'Widget', qty: 2 } })
dispatch({ type: 'remove_item', id: 1 })
Define type values as named constants or a TypeScript union to catch typos at compile
time — a misspelled string silently falls through to default with no runtime error.
type CartAction =
| { type: 'add_item'; item: Item }
| { type: 'remove_item'; id: number }
| { type: 'clear' }
When to prefer useReducer over useState
Three signals that you should reach for useReducer:
- Multiple sub-values that change together. If you always call three setters at
once (
setLoading,setError,setData), a single dispatch is cleaner. - Complex update logic. If your
useStatehandlers containif/elsetrees or switch statements, move that logic into the reducer where it's testable. - You want to share update logic. A reducer is a plain function — easy to import, compose, and unit-test without rendering.
// before: three scattered setters
function handleFetch() {
setLoading(true)
setError(null)
setData(null)
}
// after: one dispatch, all transitions in one place
dispatch({ type: 'fetch_start' })
The initializer function (third argument)
useReducer(reducer, arg, init) — the optional third argument is a function that
React calls as init(arg) on the first render to compute the initial state. This avoids
running an expensive computation on every render (the second-argument expression is
evaluated each time the component renders, even though it's only used once).
function parseSettings(raw) {
return { theme: raw.theme ?? 'dark', lang: raw.lang ?? 'en' } // expensive parse
}
const [settings, dispatch] = useReducer(settingsReducer, rawData, parseSettings)
// parseSettings(rawData) only runs on mount
The initializer also enables a clean reset action — the reducer can call init(action.payload) to rebuild fresh state from a starting value.
Sharing dispatch via context
dispatch has a stable identity, so putting it in a context doesn't cause consumers to
re-render on every state change. The pattern: a Provider holds the useReducer call and
exposes both state and dispatch through context.
const StoreCtx = createContext(null)
function StoreProvider({ children }) {
const [state, dispatch] = useReducer(reducer, initial)
return (
<StoreCtx.Provider value={{ state, dispatch }}>
{children}
</StoreCtx.Provider>
)
}
function CartButton() {
const { state, dispatch } = useContext(StoreCtx)
return (
<button onClick={() => dispatch({ type: 'clear' })}>
Clear ({state.items.length})
</button>
)
}
For large apps, split the context: one for state and one for dispatch. Components
that only dispatch don't re-render when state changes.
Testing reducers
Because a reducer is a pure function, you test it with zero React infrastructure — just call it and assert on the return value.
import { cartReducer } from './cartReducer'
test('adds an item', () => {
const state = { items: [] }
const next = cartReducer(state, { type: 'add_item', item: { id: 1, name: 'A' } })
expect(next.items).toHaveLength(1)
expect(next.items[0].id).toBe(1)
})
test('unknown action returns same state', () => {
const state = { items: [] }
expect(cartReducer(state, { type: 'unknown' })).toBe(state) // same reference
})
Testing the reducer independently is useReducer's key advantage over useState —
all business logic is in one pure function that exercises instantly.
Immer for simpler updates
Deep immutable updates (spreading nested objects) get verbose. Immer's produce
wraps your reducer so you can write mutation-looking code on a draft, and it produces
a correct immutable result.
import produce from 'immer'
const cartReducer = produce((draft, action) => {
switch (action.type) {
case 'add_item':
draft.items.push(action.item) // mutation syntax, but produces a new state
break
case 'update_qty':
const item = draft.items.find(i => i.id === action.id)
if (item) item.qty = action.qty
break
}
})
Redux Toolkit uses Immer internally for exactly this reason. If you're rolling your own
useReducer + context store, Immer is worth adding when update logic becomes nested.
useReducer vs Redux
| Feature | useReducer + context | Redux Toolkit |
|---|---|---|
| Scope | component tree | global singleton |
| DevTools | none | yes, time travel |
| Middleware | none | thunk, saga, RTK Query |
| Boilerplate | low | low (RTK) |
| Selectors | manual | createSelector + useSelector |
useReducer + context is often enough for module-level or feature-scoped state. When
you need cross-cutting global state with selectors (so only interested components
re-render), async middleware, or the Redux DevTools for debugging, Redux Toolkit earns
its dependency.
Common interview questions at a glance
- What does dispatch do? Sends an action to the reducer; React calls
reducer(currentState, action)and re-renders with the returned state. - What must a reducer not do? Have side effects or mutate its state argument.
- How is dispatch stable? React guarantees the same dispatch reference across renders — safe in dependency arrays and context.
- When would you use useReducer over useState? Multiple fields that change together, complex branchy update logic, or when you want unit-testable transitions.
- How do you reset state? Handle a
resetaction type that returnsinitialState, or call the initializer function with a starting value.