Copying and equality

Copy data at the right depth, and compare it correctly.

29of 32
≈ 10Min read
8Parts

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.

primitives are copied, objects are shared
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

and to array contents
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

pick the shallowest one that works
// 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));
Copy only the path you change

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

the pattern, spelled out
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.

the newer set
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.

identity, not contents
{ 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.

when you want the mistake to be loud
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.

Practice