Two kinds of value
Every bug about "why did that change" comes from this. An assignment copies the reference, not the thing it points at.
let a = 1;
let b = a;
b = 2;
a; // 1 — b got a copy
const x = { n: 1 };
const y = x;
y.n = 2;
x.n; // 2 — y is the same object
It applies to arguments too
function reset(obj) { obj = {}; } // reassigns the local name: no effect
function clear(obj) { obj.n = 0; } // changes the caller's object
const list = [{ n: 1 }];
const copy = [...list];
copy[0].n = 99;
list[0].n; // 99 — the array is new, the object inside is not
Copying at three depths
// one level
const a = { ...obj };
const a = Object.assign({}, obj);
const a = [...list];
const a = list.slice();
// one level, with a change
const next = { ...door, days: 10 };
const next = list.map((d) => (d.id === id ? { ...d, days: 10 } : d));
// all the way down
const deep = structuredClone(obj); // Dates, Maps, Sets, typed arrays — not functions
// the old way: loses Dates, undefined, functions, Infinity
const lossy = JSON.parse(JSON.stringify(obj));
You rarely need a deep copy. Copying the objects along the path to the change — and sharing everything else — is cheaper and is what React and every state library does.
Updating nested data without mutating
const state = {
user: { name: "Ulvi", prefs: { theme: "light" } },
doors: [{ id: 1, days: 5 }, { id: 2, days: 10 }],
};
// change one nested value
const next = {
...state,
user: {
...state.user,
prefs: { ...state.user.prefs, theme: "dark" },
},
};
// change one item in a list
const next = {
...state,
doors: state.doors.map((d) => (d.id === 2 ? { ...d, days: 12 } : d)),
};
// add and remove
const next = { ...state, doors: [...state.doors, newDoor] };
const next = { ...state, doors: state.doors.filter((d) => d.id !== 2) };
The array methods that do not mutate
Before these existed the idiom was [...list].sort(). Both are fine; the point is that plain sort, reverse, splice, push and fill change the array you were given, which may belong to somebody else.
list.toSorted((a, b) => a - b); // instead of [...list].sort(…)
list.toReversed();
list.toSpliced(1, 2);
list.with(0, "new"); // a copy with index 0 replaced
Object.groupBy(list, fn);
Comparing
Because a copy is a different object, prev !== next is a cheap and reliable "something changed" test — which is exactly why immutable updates make change detection fast.
{ a: 1 } === { a: 1 }; // false
[1, 2] === [1, 2]; // false
const x = { a: 1 };
x === x; // true
// shallow comparison — enough for most state
const shallowEqual = (a, b) => {
const ka = Object.keys(a), kb = Object.keys(b);
return ka.length === kb.length && ka.every((k) => Object.is(a[k], b[k]));
};
// and Object.is, for the two cases === gets wrong
Object.is(NaN, NaN); // true (=== says false)
Object.is(0, -0); // false (=== says true)
Freezing
Freezing is useful for constants and for catching a mutation in development. It is not free, so freezing a large state tree on every update is not the answer — discipline and copies are.
const config = Object.freeze({ retries: 3, nested: { a: 1 } });
config.retries = 5; // ignored silently (throws in strict mode)
config.nested.a = 2; // WORKS — freeze is shallow
const deepFreeze = (o) => {
Object.values(o).forEach((v) => { if (v && typeof v === "object") deepFreeze(v); });
return Object.freeze(o);
};
When mutation is fine
The rule is not "never mutate". It is never mutate something you did not create. If the value came in as an argument or from shared state, copy it.
- A local variable nobody else can see: building an array in a loop and returning it.
- Performance-critical code over a large array, where a copy per item is the cost.
- Inside a function that is documented as changing its argument — and named so:
sortInPlace.