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,toSplicedandwith(index, value)return new arrays, alongsidemap,filter,sliceandconcat. - 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); } // DataCloneErrorRemember
- Prefer returning new values: spread,
toSorted,toSpliced,with structuredClonedeep-copies (Maps, Dates, cycles) but not functions or prototypesObject.freezeis shallow and only throws on writes in strict mode
Watch out for
- Assuming
Object.freezeprotects nested objects.