Skip to the content.

Immutability is how you detect change cheaply

Immutability is how you detect change cheaply

Immutability in a frontend store is not functional-programming aesthetics. It is a performance mechanism. When you never mutate state in place — you replace the parts that changed with new objects — the framework can answer the most frequent question it asks, “did this change?”, with a single === reference comparison instead of a deep, recursive scan of the data. That one substitution, cheap identity check for expensive structural check, is what keeps re-renders and selector recomputes bounded as your state grows.

A mutated object keeps its reference; an immutable update creates a new one On the left the same object reference before and after a mutation, so an equality check sees no change. On the right a new object reference after an immutable update, so the check detects the change instantly. mutate in place ref A edit ref A A === A → "no change" (WRONG) immutable update ref A copy+edit ref B A !== B → "changed" (instant, correct)
Mutation keeps the reference, so a fast identity check misses the change. Replacing the object changes the reference, so the check catches it.

Mutation is invisible to a reference check

React’s memo, useMemo, useSelector, and reselect all compare by reference. So a push into an existing array is the classic bug: the data changed, but the array is the same object, so prev === next is true and nothing updates:

// BROKEN — same array reference, so React sees no change and won't re-render
state.items.push(newItem);
return state;

// CORRECT — a new array; the reference differs, so the change is detected
return { ...state, items: [...state.items, newItem] };

The spread copies only the top level, which is exactly enough: you create a new items array and a new state object, while unchanged siblings keep their old references and their consumers correctly skip re-rendering.

Copy only the path that changed

Immutable updates do not mean deep-cloning the whole tree — that would be slow and would change every reference, defeating the point. You copy only along the path from the root to the thing you changed; everything off that path is shared:

// update one nested field: new objects only on the root → user → prefs path
return {
  ...state,
  user: { ...state.user, prefs: { ...state.user.prefs, theme: "dark" } },
};

Now state.user.prefs is a new reference (its consumers update) but state.posts is the same reference (its consumers correctly do nothing). This “structural sharing” is why immutable state is fast, not slow.

Let a tool enforce it, then reap the checks

Writing nested spreads by hand is error-prone, which is why Redux Toolkit bundles Immer: you write code that looks mutative and Immer produces the immutable copy with correct structural sharing underneath. That is a convenience over the manual spread, not a different model — the output is still new references on the changed path. Once your updates are honestly immutable, everything downstream gets cheaper and more correct at once: memo prevents wasted renders, reselect returns stable references, and time-travel debugging can hold past states without them being mutated out from under it. The normalize-entities exercise pairs naturally with this — a normalised, immutably-updated store is where reference-based change detection pays off the most.