Immutable Data in React: The Rule I Stopped Arguing About

The company had a legacy PHP application, and we were not brought in to rebuild it. We were brought in to add one module.
One module. A self-contained piece of the product that a couple of teams needed. Nobody expected it to matter much beyond that.
So when I pushed for structure — a real data model, immutable updates, clear boundaries — there was pushback. Fair pushback. "It's one module. Why the ceremony? Why can't we just mutate the thing and move on?"
I understood the argument. On a small, disposable module, discipline can look like pure overhead. You feel every extra rule slowing you down long before it pays you back.
Then the module did the thing small modules occasionally do. It got good. It got used. Other teams started building against it. Over a couple of years it quietly stopped being a module and became the main application, with almost everything bolted onto the shape we set at the start. I still talk to the people there. They are still building on that foundation today.
I'm not telling this to claim we were geniuses. We weren't. Most of what held up under that growth was boring. And one of the most boring rules of all was this: we never mutated state in place.
That's what this article is about. Immutable data in React — not the purity lecture, but the practical discipline. Where it bites you if you skip it, when the native approach stops being pleasant, and how it connects to the reactive model from the Reatom article earlier in this series.
If you know JavaScript, you already know how to copy an object. That's not the hard part. The hard part is understanding why React, memoization, and reactive stores punish you the moment you forget.
What Mutation Actually Breaks
React doesn't deeply compare your state. It compares references.
That single sentence is the whole thing. Everything else is a consequence of it.
Here's a bug I've watched people write, debug for twenty minutes, and never fully explain afterward:
const [draft, setDraft] = useState<PolicyDraft>(initialDraft); function addBeneficiary(beneficiary: BeneficiaryDraft) { draft.beneficiaries.push(beneficiary); // mutation setDraft(draft); // same reference }
You added a beneficiary. The data changed. The screen didn't.
React ran Object.is(previousDraft, nextDraft), saw the same object reference, and decided nothing changed. Your push mutated the array in place, so the outer draft object is still the exact same reference it was before. As far as React is concerned, you called setDraft with the value it already had.
The fix isn't a trick. It's a new reference:
function addBeneficiary(beneficiary: BeneficiaryDraft) { setDraft({ ...draft, beneficiaries: [...draft.beneficiaries, beneficiary], }); }
New object, new array. React compares references, sees a difference, re-renders.
This is the React nuance the JS-fundamentals tutorials skip. Immutability in React isn't about being functionally pure for its own sake. It's that the entire rendering and memoization model is built on the assumption that a change produces a new reference. Break that assumption and everything downstream — React.memo, useMemo, dependency arrays, reactive stores — quietly stops working. Not with an error. With silence.
Mutation doesn't crash. It just makes your UI lie about the data.
The Rule: A New Reference On Every Change
The rule is boring, and boring is the point:
Never change data in place. Produce a new value.
For flat, shallow state, the native tools are enough, and I reach for them first.
Add an item:
const next = [...beneficiaries, newBeneficiary];
Remove by id:
const next = beneficiaries.filter((b) => b.id !== removedId);
Update one item by id — the operation people get wrong most often:
const next = beneficiaries.map((b) => b.id === targetId ? { ...b, share: newShare } : b, );
Notice what map does here. Every item that didn't change keeps its original reference. Only the one you touched becomes a new object. That's not an accident — it's exactly what you want. Components rendering the untouched rows can skip re-rendering, because their props are referentially identical.
Update a field on an object:
const next = { ...draft, holderName: 'Updated Name' };
None of this is clever. That's the appeal. Anyone reading it can see what changed and what stayed the same.
Where Native Updates Get Painful
Then the state gets nested, and the spreads start to hurt.
Say the policy draft looks like this:
interface PolicyDraft { holderName: string; sections: { coverage: { type: 'standard' | 'premium'; limits: { medical: number; liability: number; }; }; }; }
Now update limits.medical immutably with native syntax:
const next = { ...draft, sections: { ...draft.sections, coverage: { ...draft.sections.coverage, limits: { ...draft.sections.coverage.limits, medical: newValue, }, }, }, };
Technically correct. Practically miserable. You changed one number and wrote a five-level spread pyramid to do it. Worse, in a code review nobody can see at a glance what actually changed — the signal is buried under structural boilerplate. And every one of those spread levels is a place to make a typo that silently drops a branch of state.
This is the moment people conclude "immutability is exhausting" and start looking for shortcuts. Some of those shortcuts are good. One of them is a mistake.
Immer: The Escape Hatch for Deep Updates
When updates get deep, I reach for Immer.
Immer lets you write code that looks like mutation and hands you an immutable result:
import { produce } from 'immer'; const next = produce(draft, (d) => { d.sections.coverage.limits.medical = newValue; });
That's the same nested update as above. One line. You mutate a temporary draft object; Immer records the changes and produces a new immutable value, reusing every branch you didn't touch. That last part matters: the holderName string and the untouched liability limit keep their references, so referential-equality checks and memoization still work. You get the ergonomics of mutation with the guarantees of immutability.
My rule of thumb is narrow on purpose:
- Flat or shallow state → native spreads. Don't pull in Immer to change one field on a flat object. It's noise.
- Deep or awkwardly nested updates → Immer. The moment the spread pyramid appears,
produceearns its place.
Immer isn't free. It wraps your drafts in proxies and freezes output in development, which is a real runtime cost. For application state — forms, drafts, workflow models — that cost is invisible. I would not reach for it inside a hot render loop or a tight animation frame. Right tool, right layer.
Why I Skip Immutable.js
Every time this topic comes up, someone asks about Immutable.js. It gives you Map, List, and structural sharing baked in, so surely it's the "serious" choice?
Honestly, no. Not in a modern React codebase, and not for me.
The problem isn't the data structures. They're well made. The problem is that Immutable.js introduces a parallel type system that doesn't speak plain JavaScript:
const draft = Map({ holderName: 'Ann', beneficiaries: List() }); const name = draft.get('holderName'); // not draft.holderName const next = draft.set('holderName', 'Bob'); // not { ...draft } const plain = draft.toJS(); // needed at every boundary
That toJS() at the boundary is the tell. Your API returns plain objects. TanStack Query caches plain objects. TanStack Form, most component libraries, your logging, your TypeScript types — all assume plain objects. So you spend your life converting in and out, and every conversion is a place for bugs and lost type information. The TypeScript ergonomics are genuinely worse than working with plain interfaces.
Native immutable updates plus Immer for the deep cases cover everything Immutable.js was solving, without forcing the entire codebase to adopt a second notion of what "an object" is. I stopped considering it years ago and haven't missed it once.
Immutability and Reatom
This is where the previous article and this one meet.
In the Reatom piece, the whole model was a graph: small atoms, derived atoms, actions as transitions. That graph runs on the exact same assumption React does — a change is a new reference. Reatom decides what to recompute and what to re-render by comparing atom values by reference. Mutate an atom's value in place and you reproduce the React bug one layer down: derived atoms don't recompute, subscribed components don't update.
So the wrong version:
export const addBeneficiary = action((ctx, beneficiary: BeneficiaryDraft) => { const draft = ctx.get(policyDraftAtom); draft.beneficiaries.push(beneficiary); // mutation policyDraftAtom(ctx, draft); // same reference — nothing reacts }, 'addBeneficiary');
And the right one:
export const addBeneficiary = action((ctx, beneficiary: BeneficiaryDraft) => { const draft = ctx.get(policyDraftAtom); policyDraftAtom(ctx, { ...draft, beneficiaries: [...draft.beneficiaries, beneficiary], }); }, 'addBeneficiary');
And when the transition is deep, Immer lives comfortably inside an action:
import { produce } from 'immer'; export const setMedicalLimit = action((ctx, medical: number) => { const draft = ctx.get(policyDraftAtom); policyDraftAtom(ctx, produce(draft, (d) => { d.sections.coverage.limits.medical = medical; })); }, 'setMedicalLimit');
Immutability isn't a nice-to-have bolted onto the state graph. It's the thing that makes the graph work at all. The reactivity you get from Reatom is paid for by never mutating in place.
The Payoff
Everything so far sounds like rules. Here's what the rules buy you, and it's the reason that small module survived becoming a large application.
Memoization actually works. Because unchanged branches keep their references, React.memo and derived atoms skip work honestly. You don't fight spurious re-renders, because your data stops lying about what changed.
Undo becomes boring. The undo history from the Reatom article only works because every previous draft is a distinct, frozen-in-time reference. If you mutated in place, "history" would just be a list of pointers to the same object, all mutating together. Immutability is what makes a snapshot an actual snapshot.
const history: PolicyDraft[] = []; // every entry is a real, independent snapshot — because we never mutate history.push(currentDraft); policyDraftAtom(ctx, nextDraft);
Optimistic updates are safe. You apply the optimistic value as a new reference, keep the previous one, and roll back to it if the request fails. No cloning gymnastics, no fear that the rollback value mutated underneath you.
Debugging tells the truth. A logged state object stays what it was when you logged it. No more "it was correct in the console but wrong in the UI" — the classic symptom of an alias mutating after you inspected it.
None of these are exotic. They're the everyday properties that let a codebase absorb requirements it wasn't designed for. Which is exactly what that module had to do.
The Rule I Stopped Arguing About
The early pushback was really about ceremony. Immutability looked like ceremony — extra rules, extra keystrokes, extra discipline for a module nobody thought would grow.
But the module grew, and the ceremony turned out to be the cheapest insurance we bought. Not because immutability is profound. Because it removed an entire category of bug — the silent, intermittent, "works until it doesn't" kind — right at the point where scale makes those bugs most expensive to find.
I don't argue about immutability anymore. When someone asks whether it's worth the trouble on a small piece of code, I've stopped debating it. I just don't mutate. The habit costs almost nothing, and it holds up under exactly the pressure you can't predict when you're writing "just one module."
Next in the series, we move from data to what the user actually sees: styling in React, and why I keep coming back to Tailwind after trying most of the alternatives.
If you've got a state bug that only shows up sometimes — the kind that vanishes the moment you add a console.log — go looking for something mutating in place. And if you want to compare notes on where Immer genuinely earns its keep versus where it's overkill, reach out. I have opinions, and I'm happy to hear yours.