JavaScript objects are mutable by default, and most bugs in stateful UI code can be traced back to that fact. Something changed. Somewhere else still has the old reference. The render shows stale data. Or the change happened twice and now the cart has two of the same item.

Immutability isn't a religion — it's a tool. When state updates create new objects instead of modifying old ones, a whole class of bugs disappears. The trade-off is real, and worth understanding before you reach for structuredClone everywhere.

References Make Mutation Surprising

JavaScript passes objects by reference. So this:

JavaScript
const cart = { items: [{ id: 1, qty: 1 }] };
const snapshot = cart;

snapshot.items[0].qty = 99;
console.log(cart.items[0].qty); // 99 — both variables point to the same object

snapshot and cart are the same object. Naming it "snapshot" doesn't preserve anything. The same trap shows up when you pass state into a function "for inspection" and the function quietly mutates it. Three weeks later something renders wrong and nobody can find the cause.

Immutable updates fix this by making the mutation impossible:

JavaScript
const next = { ...cart, items: cart.items.map(i => i.id === 1 ? { ...i, qty: 99 } : i) };
// cart unchanged · next is a new object · safe to share both

Immutable Updates Make Change Visible

Immutable patterns turn "what changed?" into a quick reference comparison. React, Redux, Zustand, and basically every modern state library depend on this — they re-render when state references change, not when values inside are mutated.

JavaScript
// 🐛 React won't re-render — same reference
state.user.name = 'New';
setState(state);

// ✅ React re-renders — new reference
setState({ ...state, user: { ...state.user, name: 'New' } });

The reference change is the signal. Mutate in place and the framework can't tell anything happened.

The same pattern enables undo/redo, time-travel debugging, and Redux DevTools — every state version is a real, separate object you can keep, replay, or roll back to.

Deep Copy Is Not A Default Strategy

A common over-correction: clone everything aggressively to "stay safe." The cost is real.

JavaScript
// 🐛 cloning a 10MB object on every keystroke
const updated = structuredClone(state);
updated.user.draft = newValue;

structuredClone is the modern, faithful deep-copy primitive — it handles circular references, dates, maps, sets, even DOM types. It's also slow on large structures. Run it on every keystroke and the UI freezes.

The right approach is targeted: clone the path you're updating, share everything else. That's what { ...obj, child: { ...obj.child } } does — only the spine of the change gets new references. Library helpers like Immer do the same thing more ergonomically.

Two-row state-update timeline. The top row mutates the same object reference five times so change detection can't tell anything happened. The bottom row replaces the reference each tick, creating five distinct addresses that any subscriber can diff with prev !== next.
Mutation hides change. Replacement makes change observable for free.

Performance Still Matters

Immutability has a memory cost — every update allocates new objects. For most UI state, this is invisible. For a giant table where every row update clones the whole table — visible.

Two practical mitigations:

JavaScript
// 1. Persistent data structures (Immer, Immutable.js) share unchanged subtrees
import { produce } from 'immer';
const next = produce(state, draft => {
  draft.users[42].name = 'New';
});
// next.users[0..41] === state.users[0..41] — same references, no allocation

// 2. Update granularity — split state so updates touch small slices
// instead of one giant `appState`, separate `userState`, `cartState`, `uiState`

The pattern: make immutability cheap by making each update touch a small, scoped piece of state. A 10,000-item list shouldn't be one immutable array if you're updating one item at a time.

When To Skip Immutability

Three cases where mutation is actually fine:

  1. Local helpers building a result. Inside one function, building up an array with .push() is faster and clearer than [...arr, x] in a loop.
  2. Large derived data. Computing something once at module load and never modifying it after.
  3. Performance-critical hot paths. When a profile shows allocation pressure, targeted in-place updates can help.

Just don't expose the mutable state outside the function. The boundary is what matters.

Pro Tips

  1. Update React/store state immutably. It's required for change detection.
  2. Use spread for shallow updates — only the path that changes needs new references.
  3. Use structuredClone sparingly. It's correct but not free.
  4. Reach for Immer when nested updates get verbose. Same result, less ceremony.
  5. Object.freeze in dev to catch accidental mutations early.

Final Tips

Immutable code is more verbose and easier to reason about. Mutable code is shorter and full of distance bugs. Most modern frameworks depend on the immutable side, which makes the choice simpler than it used to be.

The shortcut: if it's state that something else observes, treat it as immutable. If it's a local computation, mutate freely.

Good luck — and may your re-renders happen for the right reasons 👊