How do you write immutable state updates in React?
Always produce new references for changed branches: `{...state, field: newValue}`, `state.map(...)`, `state.filter(...)`. For nested updates, replace the whole path (`{...state, a: {...state.a, b: 1}}`). Use Immer (`produce`) for ergonomic mutable-syntax-but-immutable-output, or RTK's createSlice which bakes it in.
Why: React bails out re-renders via Object.is equality on state. Mutation = same reference = no re-render. Plus, references are how parent components decide what changed.
Replace, don't mutate
// BAD — mutates
state.items.push(item);
setState(state);
// GOOD — new array reference
setState({ ...state, items: [...state.items, item] });Patterns
Add to array
[...arr, item]Remove from array
arr.filter((x) => x.id !== id)Update item in array
arr.map((x) => (x.id === id ? { ...x, name: newName } : x))Update object field
{ ...obj, field: newValue }Update nested
{
...state,
user: {
...state.user,
address: { ...state.user.address, city: newCity },
},
}Replace the path from root to changed leaf.
Delete object key
const { x, ...rest } = obj;
return rest;Toolkit ES2023 immutable array methods
arr.toSorted((a, b) => ...);
arr.toReversed();
arr.toSpliced(i, 1);
arr.with(i, newValue);Return new arrays without mutating.
Immer — mutable syntax, immutable output
import { produce } from 'immer';
const next = produce(state, (draft) => {
draft.user.address.city = newCity;
draft.items.push(item);
});Behind the scenes: Immer tracks mutations to a draft proxy and produces a new immutable object that shares unchanged branches.
RTK / createSlice bakes Immer in
reducers: {
add(state, action) {
state.items.push(action.payload); // "mutate" — Immer makes it immutable
},
}Why deep replacement matters
// Subtle bug
setState({ ...state, user: state.user }); // user reference unchangedComponents selecting state.user won't re-render. If you intended to update, replace user too.
Performance
- Spreads are O(width) per level — shallow.
- Immer adds proxy overhead; trivial for normal app state.
- For huge state (10k items), structural sharing matters. Immer does this; manual spreads keep it implicit if you only touch one branch.
Anti-patterns
- Direct mutation (
state.foo = 1). - Mutating after spread (
const next = {...state}; next.items.push(...)— items is still the same array). - Deep clone (
structuredClone) for every change — kills structural sharing.
Interview framing
"Always produce new references for changed branches. Add/remove/update arrays with map/filter/spread. For nested objects, replace the path from root to changed leaf. ES2023's toSorted/toReversed/toSpliced/with give immutable array variants. For complex updates, Immer's produce lets you write mutable-looking code that yields a new immutable object with structural sharing. RTK's createSlice bakes Immer in — that's why you can state.items.push inside a reducer. The reason all this matters: React bails on re-renders via Object.is equality, and selectors compare by reference. Mutation = same reference = no re-render."
Follow-up questions
- •How does Immer work under the hood?
- •Why does mutation break React?
- •When is structural sharing valuable?
Common mistakes
- •Mutating state directly.
- •Shallow spread + nested mutation.
- •Deep clone on every update.
Performance considerations
- •Structural sharing keeps spreads cheap. Avoid deep clones.
Edge cases
- •Maps and Sets — they don't spread cleanly; use new Map(old).
- •Class instances in state (Immer's defaults don't handle).
- •Cyclic state (rare; Immer warns).
Real-world examples
- •Redux Toolkit / Immer / Zustand with Immer middleware.