Immutability and mutability in JavaScript
Mutability means you can change an object's contents directly after creation. Immutability means you create a new object for any change, leaving the original untouched.
Theory
TL;DR
- Mutable objects are like a shared whiteboard: whoever holds a reference changes the same data in place
- Immutable updates work like photocopies: edits produce a fresh copy, the original stays as-is
- Main difference: mutation alters a shared memory location; immutability allocates new memory and decouples references
- Use immutability for shared state (React props, Redux store); use mutability for private, performance-critical loops
constdoes NOT make objects immutable. It only blocks reassignment.
Quick example
// Mutable: all references see the change
const mutableUser = { name: 'Alice' };
const ref = mutableUser;
mutableUser.name = 'Bob';
console.log(ref.name); // 'Bob' - shared reference changed
// Immutable: original stays intact
const immutableUser = { name: 'Alice' };
const updated = { ...immutableUser, name: 'Bob' };
console.log(immutableUser.name); // 'Alice' - unchanged
console.log(updated.name); // 'Bob'Spread (...) copies own properties into a new object at a new memory address. The two variables now point to different locations, so changes to one do not affect the other.
Key difference
JavaScript objects are reference types. A mutable operation like obj.name = 'Bob' rewrites a property in the heap slot that every variable pointing to obj shares. An immutable operation like { ...obj, name: 'Bob' } allocates fresh heap space and shallow-copies own properties into it. O(n) time cost, but the original reference is completely isolated.
When to use
- Shared UI state (React props, useState) - immutability. React compares by reference (
===), not by content. Mutation on the same reference won't trigger re-render. - Redux store - immutability. Enables time-travel debugging and predictable state history.
- Performance-critical private data (tight loops, large buffers) - mutability. No copy overhead.
- Pure functions and tested utilities - immutability. Predictable inputs and outputs, memoization works correctly.
- Single-owner local variables - mutability. Simpler code, no cost.
Comparison table
| Property | Mutability | Immutability |
|---|---|---|
| Data change | In-place on original | New object created |
| React re-render | May not trigger (same ref) | Always triggers (new ref) |
| Side effects | Possible across references | Isolated by design |
| Debugging | Harder to trace | Clear change history |
| Performance | No copy overhead | O(n) copy cost |
| Typical use | Private local data, buffers | Shared state, Redux, props |
How V8 handles this
V8 stores objects as heap-allocated structures. obj.prop = val rewrites the property slot in place via pointer dereference - no allocation, constant time. { ...obj } triggers OrdinaryObjectCreate + CopyDataProperties: new heap space, shallow copy of own enumerable properties. The copy costs O(n) relative to property count. Shallow means nested objects still share references, which is the source of the most common gotcha with spread.
Common mistakes
Assuming spread does a deep copy:
const state = { user: { profile: { friends: ['Alice'] } } };
const copy = { ...state }; // shallow copy only!
copy.user.profile.friends.push('Bob');
console.log(state.user.profile.friends); // ['Alice', 'Bob'] - original mutatedSpread copies only top-level properties. Nested objects still share the same reference. The nested mutation trap is the one I've seen catch developers most often when they first move to a React codebase. Fix: use structuredClone(state) (Node 17+, Chrome 98+) for a true deep copy.
Mutating state directly in React:
// Wrong: same reference, React skips re-render
const markDoneWrong = () => {
todos[0].done = true;
setTodos(todos); // shallow ref check fails - UI freezes
};
// Correct: new array with new object
const markDoneCorrect = () => {
setTodos(todos.map(todo =>
todo.id === 1 ? { ...todo, done: true } : todo
));
};React's reconciler uses shallow equality on references. If the reference didn't change, React skips the re-render entirely. No error, no warning - just stale UI.
Relying on const for immutability:
const arr = [1, 2];
arr.push(3); // works fine, no error
console.log(arr); // [1, 2, 3]const blocks reassignment of the binding, not mutation of the value. Use Object.freeze(arr) for shallow immutability, or full immutable patterns for anything nested.
Mutating function arguments:
// Wrong: impure, breaks React.memo and Redux selectors
function addItem(list, item) {
list.push(item);
return list;
}
// Correct: returns new array
function addItem(list, item) {
return [...list, item];
}Memoization relies on stable input references. Mutate the input and the memo cache becomes unreliable.
Expecting Object.assign to deep-clone:
const copy = Object.assign({}, state); // shallow
copy.nested.arr.push(1); // mutates state.nested.arrObject.assign copies own enumerable properties at the top level only. Same trap as spread. Use structuredClone or _.cloneDeep from lodash when you need full depth.
Real-world usage
- React useState:
setState({ ...prev, name: 'Bob' })- official docs pattern, creates new reference for reconciler - Redux Toolkit:
createSliceuses Immer internally, proxies mutations on a draft and produces an immutable patch - Immer: lets you write
state.user.name = 'Bob'insideproduce(), Immer converts it to an immutable update automatically - Lodash:
_.cloneDeep(obj)in Express middleware for safe cloning of request bodies - Node.js streams: internal Buffers are mutable for performance, but event payloads passed between handlers are copied immutably
Follow-up questions
Q: What does this log and why: const a = [1]; const b = a; a.push(2); console.log(b);
A: [1, 2]. Arrays are reference types. b and a point to the same heap location. push mutates in place, so b reflects the change.
Q: Why doesn't React re-render when you mutate state directly?
A: React's reconciler compares references with ===. If setState receives the same reference, the check reads as "no change" and the render cycle is skipped.
Q: What is the performance cost of immutability?
A: A shallow copy like spread costs O(n) where n is the number of own properties. Libraries like Immer use structural sharing: only the changed path is copied, unchanged branches keep their original reference. This cuts allocation cost significantly for large nested state.
Q: How is Object.freeze different from true immutability?
A: Object.freeze makes one object level read-only without allocating a new object. It throws in strict mode, silently fails otherwise. Nested objects remain mutable. True immutability in Redux patterns means a new reference on every update.
Q: When would you choose structuredClone over JSON.parse(JSON.stringify(x))?
A: Almost always. structuredClone handles circular references, Date, Map, Set, and typed arrays correctly. JSON.parse/stringify drops undefined and functions, turns Date into strings, and breaks on circular refs. The only reason to use JSON.parse/stringify is supporting environments before Chrome 98 or Node 17 without a polyfill.
Q (senior): How does Immer's draft proxy enable efficient immutable updates?
A: Immer wraps state in a Proxy. Inside produce(), it tracks which paths were accessed and modified. On commit, it applies structural sharing: untouched subtrees keep original references, only modified nodes are copied. A single field update on a large object costs O(depth), not O(total properties). That is why Redux Toolkit defaults to Immer instead of manual spread at every nesting level.
Examples
Shared reference gotcha
const original = { name: 'Alice', scores: [10, 20] };
const copy = { ...original };
copy.name = 'Bob';
copy.scores.push(30); // mutates original.scores!
console.log(original.name); // 'Alice' - top-level fine
console.log(original.scores); // [10, 20, 30] - nested array changedSpread creates a new top-level object but nested properties still share the same reference. To safely update a nested array: { ...original, scores: [...original.scores, 30] }.
React todo update
const [todos, setTodos] = useState([
{ id: 1, text: 'Learn JS', done: false }
]);
// Wrong: mutates original, React skips re-render
const markDoneWrong = () => {
todos[0].done = true;
setTodos(todos); // same reference - UI stays stale
};
// Correct: new array with new object for changed item
const markDoneCorrect = () => {
setTodos(todos.map(todo =>
todo.id === 1 ? { ...todo, done: true } : todo
));
};map returns a new array. The spread inside creates a new todo object. React gets a new reference, detects a change, and re-renders.
Deep clone with structuredClone
const state = {
user: { profile: { friends: ['Alice'] } }
};
// Node 17+, Chrome 98+
const safeCopy = structuredClone(state);
safeCopy.user.profile.friends.push('Bob');
console.log(state.user.profile.friends); // ['Alice'] - original safe
console.log(safeCopy.user.profile.friends); // ['Alice', 'Bob']structuredClone does a true deep copy. It handles nested objects, arrays, Date, Map, Set, and circular references. It does not copy functions or symbols, so keep that in mind for objects with methods.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.