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.
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.