Skip to main content

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
  • const does NOT make objects immutable. It only blocks reassignment.

Quick example

js
// 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

PropertyMutabilityImmutability
Data changeIn-place on originalNew object created
React re-renderMay not trigger (same ref)Always triggers (new ref)
Side effectsPossible across referencesIsolated by design
DebuggingHarder to traceClear change history
PerformanceNo copy overheadO(n) copy cost
Typical usePrivate local data, buffersShared 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:

js
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 mutated

Spread 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:

js
// 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:

js
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:

js
// 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:

js
const copy = Object.assign({}, state); // shallow copy.nested.arr.push(1); // mutates state.nested.arr

Object.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: createSlice uses Immer internally, proxies mutations on a draft and produces an immutable patch
  • Immer: lets you write state.user.name = 'Bob' inside produce(), 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

js
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 changed

Spread 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

js
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

js
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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?