Back to React
React
easy
mid

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.

3 min read·~8 min to think through

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

js
// BAD — mutates
state.items.push(item);
setState(state);

// GOOD — new array reference
setState({ ...state, items: [...state.items, item] });

Patterns

Add to array

js
[...arr, item]

Remove from array

js
arr.filter((x) => x.id !== id)

Update item in array

js
arr.map((x) => (x.id === id ? { ...x, name: newName } : x))

Update object field

js
{ ...obj, field: newValue }

Update nested

js
{
  ...state,
  user: {
    ...state.user,
    address: { ...state.user.address, city: newCity },
  },
}

Replace the path from root to changed leaf.

Delete object key

js
const { x, ...rest } = obj;
return rest;

Toolkit ES2023 immutable array methods

js
arr.toSorted((a, b) => ...);
arr.toReversed();
arr.toSpliced(i, 1);
arr.with(i, newValue);

Return new arrays without mutating.

Immer — mutable syntax, immutable output

js
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

ts
reducers: {
  add(state, action) {
    state.items.push(action.payload);   // "mutate" — Immer makes it immutable
  },
}

Why deep replacement matters

js
// Subtle bug
setState({ ...state, user: state.user });   // user reference unchanged

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

Senior engineer discussion

Seniors lean on Immer for complex updates and know when manual spreads suffice.

Related questions