Immutability Patterns in JS
Update state without mutating — spread paths, arrays, structural sharing ideas, freeze, and libraries when nested updates hurt.
- javascript
- immutability
- state
- patterns
JS doesn’t enforce immutability. Patterns do: treat values as read-only, produce new values on change. That makes React-style change detection reliable, undo stacks trivial, and race-y shared mutations rarer. You rarely deep-clone the world — you copy the path you edit.
Why bother
// mutation — same reference, UI may skip update
state.items.push(item);
setState(state);
// immutable update — new reference
setState({ ...state, items: [...state.items, item] });
Equality checks (===, Object.is, React memo) need new references for changed data.
Object updates
const user = { id: 1, profile: { bio: 'hi', city: 'Goa' }, tags: ['js'] };
// change top-level
const u1 = { ...user, active: true };
// change nested profile.bio — copy each level
const u2 = {
...user,
profile: { ...user.profile, bio: 'hello' },
};
// remove a key
const { tags, ...withoutTags } = user;
Array updates
const items = ['a', 'b', 'c'];
// append / prepend
[...items, 'd'];
['z', ...items];
// insert at i
const i = 1;
[...items.slice(0, i), 'x', ...items.slice(i)];
// update at i
items.map((item, idx) => (idx === i ? 'B' : item));
// remove at i
items.filter((_, idx) => idx !== i);
// modern copy methods
items.toSorted();
items.toReversed();
items.toSpliced(1, 1, 'x');
items.with(1, 'B');
Avoid push, splice, sort on shared state arrays unless you own a fresh copy.
Nested lists of objects
function updateTodo(todos, id, patch) {
return todos.map((t) => (t.id === id ? { ...t, ...patch } : t));
}
freeze (shallow)
const cfg = Object.freeze({ api: '/v1', opts: { retry: 1 } });
// cfg.api = '/v2' // throws in strict
cfg.opts.retry = 2; // still mutates nested — shallow freeze
deepFreeze recursively freezes for tests/dev; costly in hot paths.
Structural sharing (concept)
Immutable updates reuse unchanged branches:
user ── profile ── bio (new)
│ └── city (shared)
└── tags (shared)
Libraries (Immer, Immutable.js) automate this. Immer lets you write mutating syntax against a draft:
// conceptual Immer
const next = produce(user, (draft) => {
draft.profile.bio = 'hello';
});
When mutation is fine
- Local arrays you just created and never shared
- Performance-critical tight loops building a result you’ll freeze once
- Accumulating inside
reducebefore returning
const out = [];
for (const x of input) if (pred(x)) out.push(fn(x));
return out; // new array, fine
Interview answer
“I treat state as immutable: copy objects with spread and arrays with map/filter/slice or toSorted/with. Nested updates copy each level along the path. Object.freeze is shallow. For deep trees I use Immer or careful path copies, not JSON deep clones. Mutation is OK only for owned local builds.”
Related
Equality helpers
// shallow compare props style
function shallowEqual(a, b) {
if (Object.is(a, b)) return true;
if (typeof a !== 'object' || a === null || typeof b !== 'object' || b === null) {
return false;
}
const ka = Object.keys(a);
const kb = Object.keys(b);
if (ka.length !== kb.length) return false;
return ka.every((k) => Object.is(a[k], b[k]));
}
Immutable updates make shallow equality useful for memoization. If you mutate in place, shallow compare lies and you chase “stuck UI” bugs. Pair immutability with Object.is (not ==) so NaN and -0 behave consistently.
Further reading
Related guides
- Deep vs Shallow CopyShallow copy shares nested refs; deep copy clones the graph — spread, structuredClone, JSON limits, and React state implications.
- Event Emitter PatternImplement on/off/emit with Map of Sets — once, error isolation, memory leaks, and how this differs from DOM events.
- Functional Array PatternsPractical functional array techniques — flatMap, partitioning, indexing, zip, and when chaining hurts performance.
- AbortControllerCancel fetch and other async work with AbortController and AbortSignal — timeouts, race conditions, and cleanup when components unmount.
- Array find, some, every, includesShort-circuiting array predicates: find, findIndex, some, every, and includes — when to use each and common interview traps.