skip to content

A React component's reducer runs `state.items.push(item); return state;` and the list on screen never updates. Why does nothing re-render, and what rule is the reducer breaking?

level: middleimportance: must knowfreq 55%

answer

  1. React compares before it re-renders
  2. same reference, nothing to do
  3. push edits, it does not replace
  4. spread one level per level you change
  5. non-determinism belongs in the action

basics

~20 s

The reducer mutated the existing state and returned the same object, so React's identity comparison sees no change and bails out of re-rendering. A reducer must be pure: never mutate the state it receives, and return a new object when anything changed.

solid answer

~40 s

React compares the value your reducer returns against the current state with `Object.is`. Pushing into `state.items` and returning `state` hands back the very same reference, so the comparison says nothing changed and React skips the re-render — the array really did grow, the screen just never learns about it. The fix is to return a new object with a new array: `return { ...state, items: [...state.items, item] }`. Behind that is the general contract: a reducer must be pure — no mutation of its arguments, no fetching, no timers, no `Math.random()` or `Date.now()`, no writing to anything outside itself. React relies on that; it may call your reducer twice for the same action in development under `StrictMode`, and mutation makes the duplicate call visible as duplicated data.

code

javascript · 16 lines
javascript
function reducer(state, action) {
  switch (action.type) {
    case 'add':
      return { ...state, items: [...state.items, action.item] };
    case 'select':
      if (state.selectedId === action.id) return state;
      return { ...state, selectedId: action.id };
    default:
      throw new Error('Unknown action: ' + action.type);
  }
}

const before = { items: ['a'], selectedId: null };
const after = reducer(before, { type: 'add', item: 'b' });
console.log(Object.is(before, after)); // false -> React re-renders
console.log(Object.is(before, reducer(before, { type: 'select', id: null }))); // true -> bail out

go deeper

for a junior

Recall the rule: never mutate the state a reducer receives, and return a new object when something changed. Recognise push, splice and sort as mutating operations.

for a middle

Explain the mechanism — React compares the returned value with the previous state using Object.is and bails out when they are the same reference — and state the full purity contract including no side effects and no non-deterministic reads.

for a senior

Show that you debug this systematically: check identity of the returned value, audit for mutating methods at every nesting level, and move ids, timestamps and randomness into the action so the reducer is deterministic and unit-testable.

for a principal

Own the enforcement question: how the codebase prevents this class of bug at scale — immutable update conventions, lint rules, typing state as readonly, or an immutable-update library — and what each costs in review time and onboarding.

## What actually happened Two separate things go wrong in `state.items.push(item); return state;`, and a good answer names both. **The visible symptom is the bail-out.** After running the reducer, React compares the returned state to the state it already holds using `Object.is` — the same comparison used for `useState`. If they are the same reference, React concludes the update is a no-op and skips re-rendering the component and its children. `push` mutates the array in place and leaves the wrapper object's identity untouched, so the return value *is* the previous state. React is behaving exactly as designed; the reducer lied to it. **The deeper problem is impurity.** Even if you dodged the bail-out — say by returning `{ ...state }` after the push — you would still have edited an object React handed you and that other renders may still reference. That is the rule being broken. ## The fix Replace mutation with construction: ```js case 'add': return { ...state, items: [...state.items, action.item] }; case 'remove': return { ...state, items: state.items.filter((i) => i.id !== action.id) }; case 'rename': return { ...state, items: state.items.map((i) => i.id === action.id ? { ...i, name: action.name } : i ), }; ``` Every level you actually change gets a new object; untouched branches are shared by reference, which is what keeps this cheap. Note that `filter` and `map` already return new arrays — the mutating methods to watch for are `push`, `pop`, `splice`, `sort`, `reverse`, and direct index or property assignment. If you prefer, `toSorted` and `toReversed` are the non-mutating array counterparts of `sort` and `reverse`. ## The full purity contract A reducer is `(state, action) => nextState` and nothing else. Concretely it must not: - mutate `state` or anything reachable from it, or mutate `action`; - perform side effects — network calls, timers, logging to a server, writing to `localStorage`, dispatching; - read ambient mutable values that make the result non-deterministic, such as `Date.now()`, `Math.random()`, or a ref's `.current`. Anything non-deterministic belongs in the action: compute the id or timestamp where you call `dispatch` and pass it in as `{ type: 'add', id, createdAt }`. Then the reducer stays a plain function of its inputs. ## Why React insists Purity is not aesthetics. React calls reducers during rendering, and rendering is a phase React reserves the right to run more than once, to abandon, or to restart. In development, `StrictMode` deliberately invokes reducers twice with the same arguments to surface impurity — a mutating reducer shows up immediately as items added twice, which is the point of the check. In production, concurrent rendering can begin work and discard it; a reducer that mutated shared data would have already corrupted state that no render ever committed. ## The intentional bail-out The same identity comparison is a feature when you use it on purpose. Returning `state` unchanged is the documented way to say "this action does not apply here": ```js case 'select': if (state.selectedId === action.id) return state; // nothing to do return { ...state, selectedId: action.id }; ``` React then skips the re-render. So the rule is not "always return a new object" — it is "return a new object when something changed, and the same object when nothing did." One caveat worth knowing: React may still re-render that specific component once before settling, so the bail-out is a strong optimisation, not an absolute guarantee that no render occurs — never write logic that depends on the render not happening. ## Debugging this class of bug When state "changes" but the UI does not, the checklist is short: log the reducer's return value and compare it with `Object.is` against the incoming state; grep the reducer for mutating array methods and bare assignments to `state.x`; and remember that a deep field can be mutated even when the top-level object is fresh — `{ ...state }` copies one level only, so `{ ...state }` plus `state.items.push(...)` is still the same bug one layer down.

  • Is returning the current state from a reducer ever the right thing to do?
    Yes — it is the idiomatic way to express "this action changes nothing here". React sees the identical reference and skips the re-render, which is exactly what you want for a no-op like selecting the already-selected row. The distinction is intent: return `state` because nothing changed, never after having mutated it.
  • Where should a generated id or timestamp come from if the reducer cannot call Date.now()?
    From the action. Compute it at the dispatch site — `dispatch({ type: 'add', id: crypto.randomUUID(), createdAt: Date.now() })` — so the reducer stays a deterministic function of state and action. That also makes the reducer trivially testable, since you supply the id in the test instead of stubbing a clock.
  • A reducer returns { ...state } but the nested list still does not update in the UI. What went wrong?
    The spread copies only the top level. If the code mutated `state.items` and then spread the wrapper, the outer object is new — so React does re-render — but any memoized child comparing `items` by reference sees the same array and skips. Copy every level you actually modify, not just the outermost object.
  • Why does React call the reducer twice in development?
    `StrictMode` double-invokes reducers, and other render-phase functions, in development so impurity shows up as visibly wrong behaviour rather than as a rare production heisenbug. A pure reducer produces the same result both times and you notice nothing; a mutating one duplicates its effect, which is the signal the check exists to generate.

saying these in an interview costs you the question

  • Says React should detect the mutation and re-render anyway
  • Reaches for a forced re-render instead of returning new state
  • Believes a top-level spread deep-copies nested arrays
  • Calls Date.now() or Math.random() inside the reducer
  • Dispatches or fetches from inside the reducer

context