Difference between shallow copy vs structural sharing
Shallow copy duplicates the top-level container but reuses inner references. Structural sharing reuses every untouched subtree across versions — the basis of efficient immutable data.
These two ideas are often confused, but they solve very different problems. Shallow copy is about creating a new container; structural sharing is about creating a new version of a tree while preserving the identity of every untouched branch. The second one is the foundation of every modern state management library.
Shallow copy ({...obj}, Object.assign({}, obj), arr.slice(), Array.from(arr)) creates a brand-new top-level container, but every nested object and array inside it is still shared by reference with the original. The consequence: mutating a nested field through either reference mutates both. So const copy = {...state}; copy.user.name = "X" will silently corrupt state.user.name too. Shallow copy is cheap (O(n) in top-level keys) and is enough for flat objects, but it is not a substitute for immutability.
Deep copy (structuredClone, recursive clone, JSON round-trip) goes the other extreme: every nested object is duplicated. That's safe from mutation, but it's O(size of tree), and it throws away reference equality everywhere — so memoization, React's reconciliation, and Redux's reselect can no longer bail out on untouched subtrees.
Structural sharing is the middle ground. When you "modify" an immutable structure, only the path from the root to the changed leaf is cloned — every untouched branch is shared by reference with the previous version. The new tree is a different object, but prev.a === next.a is true whenever branch a wasn't touched. The cost of an update is O(depth) for plain objects, or O(log n) for trie-based persistent structures (HAMT in Immutable.js, Clojure's vectors). The memory cost is also O(depth), not O(size).
This is why essentially every React ecosystem library relies on reference equality:
- React's reconciliation skips a re-render when
Object.is(prevProps, nextProps)holds for memoized components. useMemo/useCallbackrecompute when their dep array's references change.- Redux's
reselectonly recomputes selectors when input slice references change. - Zustand and Jotai compare slice references with
Object.isby default.
A shallow copy gives you a cheap top-level identity check — state !== prevState — but inner refs are unchanged, so a top-level shallow copy paired with a nested mutation is exactly the bug that causes "I dispatched the action but the UI didn't update." Structural sharing fixes both halves: every changed level has a new reference, every unchanged level keeps the old one.
Tools that implement structural sharing for you:
- Immer (used inside Redux Toolkit) — write mutative-looking code on a Proxy draft; Immer produces a new tree that structurally shares unchanged branches.
- Immutable.js — HAMT-backed persistent collections (
Map,List,Set). - Mori, Mutative, Hamt+ — alternatives with different ergonomics/perf trade-offs.
Rule of thumb: use shallow copy for object literals you control end-to-end (cheap, obvious). Use structural sharing (via Immer or manual {...obj, x: {...obj.x, y}}) anywhere you put state into a store that other code reads, because referential equality there isn't a nicety — it's the contract every consumer is relying on for correctness and performance.
Code
Follow-up questions
- •How does Immer implement this without you writing the spreads?
- •Why is `===` enough for React's bail-out?
- •What's the cost of structural sharing vs naive deep clone?
Common mistakes
- •Spreading the root and assuming nested updates are isolated.
- •Using `JSON.parse(JSON.stringify(x))` for state cloning — kills structural sharing entirely.
Performance considerations
- •Structural sharing makes shallow equality usable for memoization.
- •Deep clones break referential equality, forcing re-renders.
Edge cases
- •Class instances, Maps, Sets, Dates — generic structural sharing libraries handle these inconsistently.
- •Circular references break naive cloning; Immer rejects them by design.
Real-world examples
- •React reducer + selector memoization, Apollo Client cache normalization, Redux Toolkit's `createSlice` (uses Immer under the hood).