skip to content

useReducer Patterns and Action Design

When a reducer beats several useState calls, how to keep it pure, and how to shape actions as events that happened rather than as setters. Interviewers probe reducer purity, lazy initialization, and why dispatch is safe to leave out of dependency arrays.

part ofReactoverview, primer and where to startread it →
on this pageshow

explore

questions

5

Why must the reducer function you pass to React's useReducer be pure, and where do the side effects it might want to perform belong instead?

level: middleimportance: must knowfreq 58%

answer

  1. runs during the render phase
  2. development calls it twice
  3. mutation defeats the Object.is check
  4. effects go in handlers or effects
  5. timestamps and IDs into the payload

basics

~20 s

React runs reducers during rendering and may call one twice in development, so a reducer must only compute and return the next state from its arguments. Fetching, logging, storage writes, timers and randomness belong in the event handler or an effect.

solid answer

~40 s

A reducer runs during React's render phase, and React deliberately calls it twice in development under StrictMode to surface impurity. So it must be a function of its arguments only: read `state` and `action`, return the next state, mutate nothing outside, and never mutate `state` itself. The failure modes are concrete. A `fetch` or an analytics call inside a reducer fires twice in development and can fire again whenever React re-runs the render. Reading `Date.now()` or `Math.random()` inside makes state non-reproducible and untestable. And mutating `state` then returning the same object means React's `Object.is` comparison sees no change and may skip the re-render entirely, so the UI silently goes stale. Side effects go where they are allowed: in the event handler that dispatches, or in an effect that reacts to the resulting state.

code

javascript · 20 lines
javascript
// Impure: mutates and returns the same reference
function badReducer(state, action) {
  if (action.type === 'added') {
    state.items.push(action.item);
    return state;
  }
  return state;
}

// Pure: new object, new array
function goodReducer(state, action) {
  if (action.type === 'added') {
    return { ...state, items: [...state.items, action.item] };
  }
  return state;
}

const before = { items: ['a'] };
console.log(badReducer(before, { type: 'added', item: 'b' }) === before);  // true
console.log(goodReducer(before, { type: 'added', item: 'c' }) === before); // false

go deeper

for a junior

Be able to state the contract: a reducer takes state and an action and returns the next state, and does nothing else. Know that it must return a new object rather than changing the one it was given.

for a middle

Explain why — reducers run during render and are deliberately called twice in development — and name the concrete failure of mutation: an unchanged reference lets React skip the re-render.

for a senior

Show where the effect actually belongs and why: handler for interaction-caused work, effect for synchronization, action payload for timestamps and ids. Be ready to diagnose a duplicated request traced back to an impure reducer.

for a principal

Own the convention: reducers as a pure, replayable core with impurity pushed to the edges, and what that buys — deterministic tests, action logs you can replay, and safety under concurrent rendering as the codebase adopts it.

## What "pure" means here A pure reducer satisfies two properties. It is **deterministic**: the same `(state, action)` pair always yields the same result. And it has **no observable side effects**: calling it changes nothing outside itself — no network requests, no `localStorage` writes, no DOM touching, no mutation of the objects it was handed. ```js // pure function reducer(state, action) { switch (action.type) { case 'added': return { ...state, items: [...state.items, action.item] }; default: return state; } } ``` ## Why React requires it **Reducers run during the render phase.** When you call `dispatch`, React does not immediately compute the new state; it queues the action and runs your reducer while rendering the component. The render phase is the phase React reserves the right to run repeatedly, discard, or (with concurrent rendering) interrupt and restart. Anything you do in there may therefore happen more than once, or happen for a render whose output is thrown away. **React actively double-invokes reducers in development.** In Strict Mode, React calls reducers and initializer functions twice during development renders precisely so impurity shows up as a visible bug on your machine rather than a rare one in production. A reducer that pushes an analytics event will fire it twice; a reducer that increments a module-level counter will jump by two. **Mutation defeats the bail-out check.** React compares the value the reducer returned with the previous state using `Object.is`. If you mutate the existing object and return it, the comparison says "same value" and React can skip re-rendering that component. The state "changed" but nothing on screen moved — one of the most confusing bugs in React, and it is caused purely by mutating: ```js // broken: same reference back case 'added': state.items.push(action.item); return state; // Object.is(prev, next) === true -> possible bail-out ``` **Determinism is what makes reducers testable and replayable.** The whole benefit of extracting transitions into a reducer is that you can assert `reducer(before, action) === after` with no rendering. A reducer that reads `Date.now()`, `Math.random()`, or a module-level variable cannot be asserted on and cannot be replayed from a log of actions. ## Where the effects go instead The rule of thumb: the reducer decides *what the state becomes*; something else decides *what happens in the world*. - **Caused by a user interaction → the event handler.** Fire-and-forget work triggered by a click belongs next to the dispatch, not inside the reducer: ```js function onSubmit() { analytics.track('submitted'); // effect, in the handler dispatch({ type: 'submitted' }); // pure state transition } ``` - **Needs to stay in sync with the resulting state → an effect.** If the requirement is "whenever status becomes `loading`, start the request", that is a synchronization concern and belongs in `useEffect` keyed on `state.status`, with the appropriate cleanup. - **Non-deterministic values → compute them outside and put them in the action payload.** This is the standard workaround for IDs and timestamps: the reducer stays pure and the impurity is captured once, in the handler. ```js dispatch({ type: 'added', id: crypto.randomUUID(), at: Date.now() }); ``` Now the reducer just reads `action.id` and `action.at`, and a test can supply fixed values. ## Common impurities to name in an interview - `fetch` / any network call - `localStorage`, `sessionStorage`, cookie writes - `console.log` used as production logging, analytics events - `Date.now()`, `new Date()`, `Math.random()`, `crypto.randomUUID()` - reading or writing a variable declared outside the reducer - mutating `state`, `action`, or any nested object reachable from them - dispatching another action from inside the reducer ## A note on "but it works" Impure reducers frequently appear to work, because in a simple app the reducer happens to run exactly once per dispatch. That is not a guarantee React makes. The double-invocation in development is the cheap warning; concurrent rendering behaviour is the expensive one. Treat "it worked in my test" as luck, not as evidence.

  • A reducer mutates state.items and returns the same state object. What exactly goes wrong?
    React compares the returned value to the previous state with `Object.is`. Because the reference is unchanged, it can bail out and skip re-rendering that component — the data changed but the UI does not update. Worse, the mutation may already be visible to code holding the old object, so debugging shows "correct" state with a stale screen. Returning a new object fixes both.
  • An action needs a generated id and a timestamp. How do you keep the reducer pure?
    Generate them at dispatch time and put them in the action: `dispatch({ type: 'added', id: crypto.randomUUID(), at: Date.now() })`. The reducer then just reads `action.id` and `action.at`, so it stays deterministic and a test can pass fixed values. Capturing the impurity once, at the call site, is the standard pattern.
  • You need a network request when the status becomes 'loading'. Where does it go?
    Not in the reducer. If the request is caused by a user interaction, put it in the event handler alongside the dispatch. If the requirement is genuinely "stay in sync with this state", put it in an effect keyed on the status, with cleanup for the case where the state changes again before the request settles.

saying these in an interview costs you the question

  • A quick fetch inside the reducer is fine
  • Mutating state is fine because React copies it anyway
  • Reducers only run once per dispatch, guaranteed
  • Reading Date.now() in a reducer is harmless
  • Logging inside a reducer counts as pure

context

open as a page

In React, what signals tell you a component should move from several useState calls to a single useReducer, and what do you actually gain by doing it?

level: middleimportance: must knowfreq 70%

basics

~20 s

Move to useReducer when several state fields change together on one event, when the next value depends on the previous one, or when the same update logic is repeated across handlers. You gain a single pure transition function you can test on its own.

open as a page

In React, why is the dispatch function returned by useReducer safe to leave out of a useEffect dependency array, and what does that stability let you avoid elsewhere?

level: juniorimportance: should knowfreq 45%

basics

~20 s

React guarantees that the dispatch function keeps the same identity for the whole lifetime of the component, so listing it in a dependency array can never cause the effect to re-run. That stability also means dispatch needs no useCallback when passed to memoized children.

open as a page

A React component calls const [state, dispatch] = useReducer(reducer, buildInitialState(items)), where buildInitialState is expensive. What happens on every re-render, and how do you fix it?

level: middleimportance: should knowfreq 45%

basics

~20 s

buildInitialState(items) is an ordinary argument, so JavaScript evaluates it on every render even though React only uses its result on the first one. Pass the raw value plus the function as useReducer's third argument so React calls the initializer only on mount.

open as a page

In a React useReducer, what is the difference between dispatching { type: 'setStatus', value: 'loading' } and dispatching { type: 'submitted' }, and why do reviewers push for the second style?

level: seniorimportance: should knowfreq 42%

basics

~20 s

The first is a remote-control setter: the component decides the new state and the reducer just writes it. The second names what happened and lets the reducer decide the consequences, so the rules live in one place and each user interaction is one action.

open as a page