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:
| Input | Comes back as |
|---|---|
new Date() | an ISO string |
undefined (as a property value) | the key is dropped entirely |
NaN, Infinity | null |
new Map(), new Set() | {} |
| a function | the key is dropped |
BigInt | throws TypeError |
| a circular reference | throws 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.
- Challengemedium
Deep Clone
Write the recursive clone yourself, including the cycle case that breaks the naive version.
- Challengeeasy
Deep Equal
The mirror problem: comparing structures forces you to be precise about what "the same" means.
- ChallengemediumPro
Deep Merge
Merging is copying with a conflict rule — arrays and nested objects each need a decision.