TL;DR / Structural sharing reuses unchanged portions of a data structure when creating an updated copy, avoiding full deep clones while preserving immutability.
How It Works
┌────────────┐ ┌────────────┐
│ State v1 │ │ State v2 │
└────────────┘ └────────────┘
│ │
│ ┌┘
│ │
↓ ↓
┌─────────┐ ┌───────────────┐
│ A = 1 │ │ A = 5 (new) │
└─────────┘ └───────────────┘
│ │
└───────┐ ┌─────────┘
│ │
│ │
│ ┌─────────────┐ │
└─│ B = {x:2} │──┘
└─────────────┘
shared (same ref)
When you update a value inside a deeply nested immutable data structure, a naive approach would deep-clone the entire thing. Structural sharing avoids that cost by reusing every node in the tree that was not on the path to the changed value. Only the modified node and its ancestors get new allocations — everything else keeps its original reference.
Consider a state object with properties a, b, and c, where b contains a nested object with properties x and y. If you update b.x, structural sharing creates a new root object, a new b object, and sets b.x to the new value. The references to a, c, and b.y remain identical to the previous version. Both the old and new state trees coexist in memory, sharing the unchanged portions.
This technique is the backbone of persistent data structures. Libraries like Immer and Immutable.js implement it, as does Redux Toolkit under the hood (via Immer's copy-on-write proxies). React's own state model depends on it — when you call a state setter with a new object, React expects unchanged subtrees to maintain referential equality so it can bail out of unnecessary re-renders.
The Path-Copying Algorithm
The core algorithm is path copying. Given a tree and a path to the node being changed, the algorithm walks from the root to the target node. At each level, it creates a shallow copy of the current node (one level deep), then replaces the child pointer that leads toward the changed value with a new reference. All sibling pointers remain the same. This means the cost of an update is O(depth) rather than O(n), where n is the total number of nodes.
For a flat object with 100 keys where you change one key, structural sharing creates exactly one new object with 100 property slots — 99 of which point to the same values as before. The memory overhead is one object allocation regardless of how large the unchanged portion is.
Why It Matters for React
React's reconciliation relies heavily on reference checks. When a component receives props, React compares old and new values with ===. If a prop's reference hasn't changed, React knows it can skip re-rendering that subtree. Structural sharing makes this optimization possible: unchanged parts of state maintain their identity across updates.
Without structural sharing, you'd either mutate state (breaking React's change detection entirely) or deep clone (creating new references everywhere, defeating memoization). Structural sharing sits at the sweet spot — new identity for changed paths, preserved identity for everything else.
Immer's Proxy-Based Approach
Immer uses ES6 Proxies to let you write code that looks like mutation while producing structurally shared immutable updates behind the scenes. When you write draft.user.name = "Alice" inside a produce callback, Immer's proxy intercepts the write, marks user and its parent as modified, and at the end of the callback creates new copies only for the marked nodes. Every unmodified branch of the state tree retains its reference.
This is why Redux Toolkit adopted Immer — it gives developers mutation-style ergonomics while preserving the referential equality guarantees that React and useSelector depend on.
Structural Sharing in Persistent Collections
Libraries like Immutable.js take structural sharing further with hash array mapped tries (HAMTs). Instead of plain objects, data is stored in a tree of small arrays (typically 32-wide). Updates create new nodes along the path and share all other branches. This gives O(log32 n) update and lookup time — effectively constant for practical sizes — with minimal memory overhead. The trade-off is that these are not plain JS objects, so interop requires conversion at boundaries.
Gotchas
- Spread operator only does shallow copying.
{...state, nested: {...state.nested, x: 5}}achieves structural sharing manually, but it is error-prone for deep structures — miss a level and you either mutate or break sharing. - Deep equality checks defeat the purpose. If you use
_.isEqualorJSON.stringifyto compare state, you pay the full traversal cost and gain nothing from preserved references. Use===to leverage structural sharing. - Immer's
producemust return undefined or a new value — not both. If you modify the draft and also return a value, Immer throws. This catches a class of bugs where you accidentally mix mutation and immutable returns. - Structural sharing only works along the updated path. If you replace the root entirely (e.g., fetching fresh state from an API), all references change and all consumers re-render. Merge incoming data selectively to preserve sharing where possible.
- Frozen objects in development (e.g., Immer's auto-freeze) catch accidental mutations but add overhead. Disable freezing in production builds to avoid the performance cost.