skip to content

A Redux reducer runs `state.lists[id].items.push(item)` and returns `{ ...state }`, yet the list component never re-renders. Why, and how do you fix it?

level: seniorimportance: must knowfreq 60%

answer

  1. spread copies one level only
  2. the array reference never changed
  3. copy every level on the path
  4. history shares the mutated array

basics

~20 s

The spread copies only the root; lists, the list and its items array keep their references, and push mutated that array in place, so a selector returns the same array and the component skips rendering. Copy every level on the path.

solid answer

~40 s

`{ ...state }` is a shallow copy: the new root still points at the same `lists` object, the same list object and the same `items` array, and `push` changed that array in place. A component that selects `state.lists[id].items` gets back the identical array, and react-redux compares the selected value by reference by default, so it decides nothing changed. Worse, the previous state object shares that array, so history and debugging tools now show the item in the past state too. The fix is an immutable update of every object on the path: a new root, a new `lists`, a new list and a new `items` array built with `[...list.items, item]`, while untouched branches keep their references. In practice Redux Toolkit's Immer-based reducers write this for you, and flatter, normalized state keeps these paths short.

code

ts · 24 lines
ts
import type { UnknownAction } from 'redux'

interface ListsState {
  lists: Record<string, { title: string; items: string[] }>
}

export function listsReducer(state: ListsState = { lists: {} }, action: UnknownAction): ListsState {
  switch (action.type) {
    case 'lists/itemAdded': {
      const { id, item } = action.payload as { id: string; item: string }
      const list = state.lists[id]
      if (!list) return state // unknown list: no change, same reference
      return {
        ...state,
        lists: {
          ...state.lists,
          [id]: { ...list, items: [...list.items, item] },
        },
      }
    }
    default:
      return state
  }
}

go deeper

for a junior

Remember that the spread operator copies one level, and that push, splice and sort change the existing array instead of creating a new one.

for a middle

Trace the references level by level, explain why a reference comparison misses the change, and write the full path-copying update.

for a senior

Diagnose from symptoms: stale UI next to a correct action log, past snapshots containing new data; then prevent recurrences with Immer, normalization and mutation checks.

for a principal

Decide how a codebase guarantees immutability: tooling that makes mutation impossible or detected, versus relying on review discipline in hand-written reducers.

## The bug ```ts case 'lists/itemAdded': { const { id, item } = action.payload as { id: string; item: string } state.lists[id].items.push(item) // mutates the existing array return { ...state } // new root, old everything else } ``` It *looks* immutable because the reducer returns a new object. It is not. ## What actually changed Follow the references from the root down: | Level | Before | After | Same reference? | |---|---|---|---| | Root `state` | object R1 | spread copy R2 | No | | `state.lists` | object L | object L | **Yes** | | `state.lists[id]` | object X | object X | **Yes** | | `state.lists[id].items` | array A (2 items) | array A (3 items) | **Yes**, mutated in place | Only the root is new. Everything below it is shared with the previous state, and the array was modified where it sits. ## Why the component does not re-render Redux decides nothing about rendering itself. After the dispatch, the store notifies subscribers, and react-redux reruns each component's selector: - A component selecting `state.lists[id].items` gets **array A again**. By default react-redux compares the new selection with the previous one using `===`; A is A, so it skips the render, even though A's contents changed. - A component selecting `state.lists` gets **object L again**, so it skips too. - A component that selects a **primitive derived from the array**, such as `items.length`, *does* re-render, because `3 !== 2`. That is why this bug often looks intermittent: the badge count updates while the list below it stays stale. - A component that selects the root object itself would also re-render, but selecting the whole root is rare and discouraged. If the reducer were a slice under `combineReducers`, the same logic applies one level up: a slice that returns the same reference counts as unchanged. ## The second symptom: corrupted history The previous state object R1 still points at array A. Because A was mutated, R1 now *also* contains the new item. Anything that kept R1, such as undo stacks, a debugging tool's history or a test snapshot, now shows the item as if it had always been there. This is why the Redux style guide calls mutation the most common cause of bugs, including components failing to re-render and broken time-travel debugging. ## The fix: copy the whole path Create a new object or array at **every level from the root down to the change**, and reuse everything else: ```ts case 'lists/itemAdded': { const { id, item } = action.payload as { id: string; item: string } const list = state.lists[id] return { ...state, lists: { ...state.lists, [id]: { ...list, items: [...list.items, item] }, }, } } ``` Now the root, `lists`, the list and `items` are all new, while other lists and other top-level slices keep their references. Components that read this list re-render; components that read other lists do not. ## Fix checklist 1. **Never call mutating methods on state**: `push`, `splice`, `sort`, `reverse`, or assignment to `state.x`. Use `concat` or spread, `filter`, `map`, and `toSorted` or a copied array. 2. **Copy each ancestor** of the changed value, not just the root. 3. **Keep untouched siblings** by reference so unrelated components do not re-render. 4. **Flatten the shape.** Normalized state (`byId` maps with ID references) keeps update paths one or two levels deep. 5. **Let tools write it.** Redux Toolkit's reducers use Immer, so "mutating" code there produces a correct immutable update. That is a Toolkit feature; in a hand-written reducer, the mutation is real. 6. **Catch it in development.** Toolkit's `configureStore` adds a development-only check that throws when state is mutated between dispatches; with the plain core, the style guide suggests a mutation-detection middleware. ## Why the store cannot catch it The Redux core does not help here, by design: - It **never freezes** state, so `push` succeeds silently. - It **never compares** old and new state; it saves whatever the reducer returns and notifies every listener. - It **cannot see inside** your objects; change detection happens later, in subscribers, and only by reference. So the reducer is the one place where immutability is either kept or lost. ## Mutating methods and safe replacements | Mutates the array | Returns a new array instead | |---|---| | `push(x)` | `[...arr, x]` or `arr.concat(x)` | | `splice(i, 1)` | `arr.filter((_, j) => j !== i)` | | `arr[i] = x` | `arr.map((v, j) => (j === i ? x : v))` | | `sort(fn)` | `arr.toSorted(fn)` or `[...arr].sort(fn)` | | `reverse()` | `arr.toReversed()` or `[...arr].reverse()` | ## How to spot it fast - The Redux log shows the action and the new state contains the item, but the UI is stale. - A **previous** state snapshot mysteriously contains the new data as well. - A forced re-render, for example from typing elsewhere on the page, suddenly shows the item.

  • Why not just deep-clone the whole state with structuredClone in the reducer?
    It fixes the missing references but creates new references for everything, so every component that selects any object from that slice re-renders on every change, and cloning a large tree on each dispatch is slow. Immutable updates are cheap because they copy only the path to the change and share the rest.
  • How do you find which reducer is mutating state in a large codebase?
    Turn on mutation detection in development: Redux Toolkit's configureStore includes a check that throws with the mutated path when state changes between dispatches, and with the plain core the style guide points to a mutation-detection middleware. In reducer unit tests, deep-freeze the input state so any in-place write throws right in the reducer that did it.

saying these in an interview costs you the question

  • Returning a new root object means the whole state is new
  • Redux deep-compares state, so an in-place push is still detected
  • Mutation is only a style issue as long as the UI updates eventually
  • Deep-cloning the state on every action is the safe immutable pattern
  • Past states in history are unaffected by later mutations