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:
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:
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.
// 🐛 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.
// 🐛 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.
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:
// 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:
- Local helpers building a result. Inside one function, building up an array with
.push()is faster and clearer than[...arr, x]in a loop. - Large derived data. Computing something once at module load and never modifying it after.
- 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
- Update React/store state immutably. It's required for change detection.
- Use spread for shallow updates — only the path that changes needs new references.
- Use
structuredClonesparingly. It's correct but not free. - Reach for Immer when nested updates get verbose. Same result, less ceremony.
Object.freezein 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 👊




