Immutability and copying

JavaScript · Advanced · 12 min

Shared mutable state causes some of the hardest bugs, and UI frameworks rely on immutable updates.

Objects and arrays are shared by reference, so a function that mutates its argument changes the caller's data. Many bugs — and every React state update — come down to whether something was mutated or replaced.

Producing new values instead of mutating is the main technique:

  • Spread for shallow copies with changes: { ...user, name }, [...list, item].
  • ES2023 change-by-copy array methods: toSorted, toReversed, toSpliced and with(index, value) return new arrays, alongside map, filter, slice and concat.
  • For nested updates, copy each level along the path you change and share the rest: { ...state, user: { ...state.user, name } }.

Deep copies: structuredClone(value) (browsers and Node 17+) copies nested objects, arrays, Map, Set, Date, typed arrays, and even circular references. It throws a DataCloneError for functions and DOM nodes, and class instances come back as plain objects without their prototype.

Freezing: Object.freeze(obj) makes existing properties read-only and prevents adding or removing properties. Writes are silently ignored in sloppy mode and throw TypeError in strict mode (modules, classes). But freeze is shallow: nested objects remain mutable unless you freeze them too. Object.seal allows changing values but not adding or deleting keys; Object.isFrozen checks.

Remember const only prevents rebinding a variable; it doesn't freeze anything.

Trade-offs: copying costs allocation and time, which matters for large collections in hot loops. Libraries like Immer let you write mutating code that produces structurally shared immutable results.

Example

"use strict";
const state = Object.freeze({
  user: { name: "Ada", tags: ["admin"] },
  scores: [3, 1, 2],
});

const sorted = state.scores.toSorted();          // copy, original intact
console.log(sorted, state.scores);               // [ 1, 2, 3 ] [ 3, 1, 2 ]
console.log(state.scores.with(0, 99));           // [ 99, 1, 2 ]

try { state.scores = []; } catch (e) { console.log(e.name); } // TypeError
state.user.name = "Grace";                       // allowed: freeze is shallow!
console.log(state.user.name);                    // Grace

const next = { ...state, user: { ...state.user, tags: [...state.user.tags, "dev"] } };
console.log(next.user.tags, state.user.tags);    // [ 'admin', 'dev' ] [ 'admin' ]

const original = { when: new Date(0), ids: new Set([1]) };
original.self = original;                        // circular
const deep = structuredClone(original);
console.log(deep.when instanceof Date, deep.self === deep); // true true
try { structuredClone({ fn() {} }); } catch (e) { console.log(e.name); } // DataCloneError

Remember

  • Prefer returning new values: spread, toSorted, toSpliced, with
  • structuredClone deep-copies (Maps, Dates, cycles) but not functions or prototypes
  • Object.freeze is shallow and only throws on writes in strict mode

Watch out for

  • Assuming Object.freeze protects nested objects.

Check yourself

  1. What happens with structuredClone({ save() {} })?
  2. const a = [3, 1, 2]; const b = a.toSorted(); — what is a afterwards?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: developer.mozilla.org/en-US/docs/Web/API/Window/structuredClone