codejump
Academy

Shallow copy vs deep copy

Why spreading an object still shares its nested values, what structuredClone fixes, and what JSON round-tripping quietly destroys.

Updated

What a shallow copy copies

A shallow copy makes a new container and copies each value one level down. For a primitive, the value *is* the data. For an object or array, the value is a reference — so the copy points at the same thing the original does.

const original = { name: 'Ada', tags: ['math'] };
const copy = { ...original };

copy.name = 'Grace';       // independent — strings are copied by value
copy.tags.push('logic');

original.tags;             // ['math', 'logic']  ← shared

{ ...obj }, Object.assign({}, obj), arr.slice(), [...arr], Array.from(arr) and arr.map(x => x) are all shallow. This is not a shortcoming to route around — it is the right default, and it is almost always what you want. The bug is not shallow copying; the bug is shallow copying while believing it was deep.

Where this bites

It bites in state management, always for the same reason: a framework compares references to decide whether something changed.

const next = { ...state };
next.user.settings.theme = 'dark';   // mutates the ORIGINAL state too
setState(next);

next is a new object, so the top-level reference changed and the component re-renders. But state.user.settings was mutated in place, so anything that memoised on user sees the same reference, decides nothing changed, and does not update. Half the UI moves and half does not — a bug that looks like a rendering problem and is really a copying problem.

The fix is to copy every level you intend to modify:

const next = {
  ...state,
  user: {
    ...state.user,
    settings: { ...state.user.settings, theme: 'dark' },
  },
};

Verbose on purpose: each spread marks a level you are taking ownership of.

structuredClone — the real deep copy

Built into every modern browser and Node 17+:

const deep = structuredClone(original);

It handles what hand-written cloners get wrong: Date, Map, Set, RegExp, ArrayBuffer, typed arrays, and circular references. A naive recursive clone hits a cycle and blows the stack.

It cannot clone functions, DOM nodes, class instances (you get a plain object — the prototype is lost), or property descriptors such as getters and setters. Those throw or are silently flattened, so it is not a universal deep copy, just a correct one for data.

Why JSON.parse(JSON.stringify(x)) keeps being wrong

It is the one-liner everyone knows, and it is lossy in ways that stay hidden until production data contains one of them:

InputComes back as
new Date()an ISO string
undefined (as a property value)the key is dropped entirely
NaN, Infinitynull
new Map(), new Set(){}
a functionthe key is dropped
BigIntthrows TypeError
a circular referencethrows TypeError

The Date row is the expensive one. Nothing fails — you simply now have a string where the rest of the code expects a Date, and you find out at the first .getTime().

Choosing

  • Flat object, or you only touch the top level → spread. Cheapest and clearest.
  • You know exactly which nested path changes → nested spreads. Explicit is a feature in state code.
  • Arbitrary data, unknown depth, possible cycles → `structuredClone`.
  • Class instances with methods → neither. Give the class a clone(); only it knows what its invariants are.

Now practice it

Reading this page is the cheap half. These are the exercises that make you use it.