Maximize node reuse between two immutable state trees.
optimizeImmutableUpdate(prev, next) deeply compares two versions of an immutable tree and returns a new tree where every unchanged subtree is the same reference it was in prev, while changed subtrees come from next. If nothing changed at all, it returns prev as is.
This makes reference-equality tools — React.memo, useMemo, useSelector, useSyncExternalStore, reselect, Vue computed, change-detection hacks — actually work for deeply updated immutable state, and eliminates redundant allocations of identical nodes.
- The problem
- The solution
- Where it pays off
- Installation
- Usage
- API
- Matching array items by id
- Behavior notes
- Alternatives
- Recipes
- React hooks
- Benchmarks
- Development
- Motivation
Immutable updates (spread, structuredClone, immer, normalized reducers…) create a new tree: every untouched branch is a fresh object with a new identity, even when its content is identical.
const prev = {
user: { name: 'Ann' },
cart: { total: 100 },
}
const next = {
user: { name: 'Ann' }, // same content, but a NEW object
cart: { total: 150 },
}
next.user === prev.user // false — memoization is brokenReact.memo(() => …) re-renders, selectors recompute, effects re-run — only because a deep-but-unchanged node got a new reference.
import optimizeImmutableUpdate from 'optimize-immutable-update'
const state = optimizeImmutableUpdate(prev, next)
state === next // true — the tree really changed
state.user === prev.user // true — unchanged subtree reused, zero allocations
state.cart === next.cart // true — changed subtree taken from nextAnd when the whole tree is unchanged, the original reference is kept, so consumers can cheaply detect "nothing happened":
optimizeImmutableUpdate(prev, identicalNext) === prev // trueThe library targets apps where fresh immutable trees arrive constantly while their content mostly stays the same:
- polling and background refetching — dashboards, feeds, monitoring,
- server push over WebSocket/SSE,
- store hydration from server snapshots,
- normalized caches updated after every mutation.
Every parsed response is a tree of brand-new objects, so on every tick — even when the payload is byte-identical to the previous one — the app silently does a cascade of work:
- selectors,
useMemoanduseCallbackrecompute, React.memocomponents re-render because their props are "new" objects,- React reconciles those subtrees — and the DOM ends up unchanged,
- the freshly allocated copy of the tree immediately turns into garbage, feeding GC pressure.
That work is proportional to tree size × number of consumers × update frequency, and all of it exists just to discover "nothing actually changed". Applied once at the data boundary, optimizeImmutableUpdate replaces the whole cascade with a single deep pass over the incoming tree: unchanged subtrees keep their old references and never reach the re-render machinery at all.
The effect is most dramatic when:
- responses are large but change in one small branch — one row of a list, one field of a form: everything else keeps its references;
- the server reorders list items — id-based matching keeps every row object identical, so keyed
React.memorows skip rendering entirely; - responses repeat without changes — the function returns the old
prevreference, anduseSyncExternalStore/selector subscribers detect "nothing happened" for free.
Honest cost: the optimizer itself is one extra deep comparison per update (O(size of next)). When trees really change wholesale every time, it adds that pass without saving downstream work — though untouched branches still get stable references, which may pay off through consumer memoization alone.
npm install optimize-immutable-updateor
yarn add optimize-immutable-updateor
pnpm add optimize-immutable-updateRequires Node.js >= 20 (runtime dependency is only is-plain-object). TypeScript declarations are bundled.
React is not required and is never installed as a dependency: the React hooks subpath uses whatever React (>= 16.8) your project already has.
import optimizeImmutableUpdate from 'optimize-immutable-update'
const optimizedNextValue = optimizeImmutableUpdate(prevValue, nextValue)A typical place is right where a fresh immutable tree is produced — after a reducer, a store update, or a deep clone:
// Redux-style
function rootReducer(prevState: State, action: Action): State {
// …any immutable update logic: spread, immer, cloneDeep…
const nextState = nextStateFrom(prevState, action)
return optimizeImmutableUpdate(prevState, nextState)
}Now every reference-equality consumer down the stream sees stable identities for untouched branches:
const UserAvatar = memo(function UserAvatar({ user }: { user: User }) {
return <Avatar name={user.name} />
})
// After optimization an update to `cart` no longer re-renders UserAvatar:
// state.user keeps pointing to the exact object from the previous render.function optimizeImmutableUpdate<S>(
prev: S | undefined,
next: S,
idStructure?: IOptimizeImmutableUpdateIdStructure,
): S
type IOptimizeImmutableUpdateIdStructure =
| undefined // auto-detect item ids
| string // key name to match array items
| IOptimizeImmutableUpdateIdStructure[] // per-index structure for arrays
| { [key: string]: IOptimizeImmutableUpdateIdStructure } // per-key structure for objectsRecursively merges next onto prev with maximum node reuse:
| Value kind | Result |
|---|---|
prev is undefined |
next as is |
next === prev (same reference) |
prev |
Primitives, null, functions |
next |
Date |
prev when times are equal, otherwise next |
RegExp |
prev when source + flags are equal (lastIndex ignored), otherwise next |
| Arrays | Deeply merged; items matched by id (see below), order/inserts/removals supported |
| Plain objects | Deeply merged per key, unchanged keys keep prev references |
Class instances, Symbol, Map, Set, … |
next (reference comparison only) |
An object branch is considered unchanged when both key sets and all values match — in that case the whole prev object reference is returned.
The
nexttree is mutated in place: wherever an unchanged subtree is found, its slot innextis overwritten with theprevreference. Pass a fresh, single-use tree (the normal product of an immutable update) and use the return value.
How idStructure scopes down the tree:
| Form | Scope |
|---|---|
undefined |
auto-detection (id, then uuid) at every array level |
'code' |
the top-level array only; deeper levels fall back to auto-detection |
{ users: 'userId', settings: undefined } |
the named object keys, recursing into each value's own structure |
['rowId', undefined] |
the top-level array items by position; nested arrays follow their entry's own structure |
Arrays are not merged positionally. When items are objects with an identifier, the optimizer matches next items to prev items by id, so reordering, inserting, removing and swapping keep untouched items reusable even if their positions changed:
const prev = [{ id: 1, name: 'Ann' }, { id: 2, name: 'Bob' }]
const next = [{ id: 2, name: 'Bob' }, { id: 1, name: 'Ann' }]
const result = optimizeImmutableUpdate(prev, next)
result[0] === prev[1] // true — the very same object, despite the new position
result[1] === prev[0] // trueBy default the item key is auto-detected: id first, then uuid (probed on the first array item). Use the third argument when your items are keyed differently or when nested arrays need their own rules:
// 1. Custom key name for array items
optimizeImmutableUpdate(prev, next, 'code')
// 2. Per-key structure: which key to use for each object property
optimizeImmutableUpdate(prev, next, {
users: 'userId', // match `users` items by `userId`
settings: undefined, // fall back to auto-detection
})
// 3. Per-index structure for arrays
optimizeImmutableUpdate(prev, next, {
matrix: [
undefined, // first row: auto-detect ids
'rowId', // second row: match by `rowId`
],
})Arrays of primitives (or items without ids) are compared element-wise by position.
- The
nexttree is mutated in place: wherever an unchanged subtree is found, its slot innextis overwritten with theprevreference. Pass a fresh, single-use tree (the normal product of an immutable update) and use the return value. - When nothing changed, the function returns the original
prevobject — a cheap "no changes" signal. - Comparisons use
Object.is: equalNaNreusesprev;+0vs-0counts as a change. DateandRegExpare compared by value —source+flagsfor the latter; the mutablelastIndexis ignored.- Non-plain objects (class instances,
Map,Set,Symbol, …) are treated as opaque values and compared by reference only. - Holes in sparse arrays are preserved: no slot is materialized as
undefined, and a hole whereprevhad a value (or vice versa) counts as a change. - Non-writable
nextslots (frozen/sealednext, getter-only properties) degrade gracefully: the reuse write is skipped instead of throwing, so a deep-equal frozennextstill returnsprev, a changed one returnsnextas-is — identical in strict and sloppy mode. - Duplicate ids in a
prevarray collapse to the last item in the id map — output stays correct, reuse degrades. - Duplicate ids in a
nextarray may reuse the sameprevitem for several positions — values stay correct, but distinct input items can end up aliasing one reference. - Per-index
idStructureentries resolve by position innext: after a reorder a matched item may be optimized with a "wrong" entry — prefer key-based structures for reorderable arrays. - Cyclic references are not supported (recursion has no memo of visited nodes).
Libraries that align ready-made trees with maximum reference reuse:
| Library | Input | Unchanged subtrees keep old refs | Array items matched by id | Id key auto-detected | Circular refs | Map/Set values |
|---|---|---|---|---|---|---|
| optimize-immutable-update | two full trees | ✅ | ✅ id/uuid |
✅ | ❌ | ❌ opaque |
TanStack Query replaceEqualDeep |
two full trees | ✅ | ❌ positional | ❌ | ❌ | ❌ |
gattai-merge |
target + partial sources | ✅ | ❌ | ✅ | ✅ merged | |
@b2m9/keyfold |
base + partial delta | ✅ | ✅ via keyBy |
❌ | ❌ | ❌ JSON-shaped only |
emerge |
two dicts (variadic) | ✅ | ❌ positional | ❌ | ❌ | ❌ dicts only |
✅ out of the box ·
- TanStack Query
replaceEqualDeep— the defaultstructuralSharingof TanStack Query (@tanstack/query-core), assumes JSON-serializable data; this package is a drop-in replacement for that option (see Recipes). gattai-merge— merge semantics: keys present only in the target survive; wins on circular references and mergedMap/Set.@b2m9/keyfold— partial-delta semantics (RFC 7396, explicit deletes); identities are strict — duplicate or missing ids throw, where this package degrades gracefully (see Behavior notes).emerge— Clojure-inspiredpatch/mergeover plain dicts (a non-dict root throws), reusing deep-equal parts even when overrides are redundant; arrays merge positionally, without id matching.
Producer-based tools (immer and friends) create structural sharing while the update is being written, not afterwards — a different insertion point, so they are out of scope here.
TanStack Query already preserves unchanged references via its default structuralSharing (replaceEqualDeep), but it matches array items positionally. optimizeImmutableUpdate is a drop-in replacement that also matches items by id/uuid, so a reordered or shifted response no longer invalidates memoized rows:
import { QueryClient } from '@tanstack/query-core'
import optimizeImmutableUpdate from 'optimize-immutable-update'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
structuralSharing: optimizeImmutableUpdate,
},
},
})Any (prevData, data) => data function fits the option, so the whole idStructure toolbox works here too.
import { useOptimizedMemo } from 'optimize-immutable-update/react'
function TodoListContainer({ state }: { state: State }) {
// deps change on every fetch, but rows keep prev objects for unchanged todos
const todos = useOptimizedMemo(() => state.todos, [state.todos])
return <TodoList todos={todos} />
}
// memoized rows don't re-render while their todo object is reused:
const TodoRow = memo(function TodoRow({ todo }: { todo: Todo }) {
return <li>{todo.title}</li>
})The same without hooks — right where a fresh tree is produced:
const todos = optimizeImmutableUpdate(prevTodos, nextTodos)const selectItems = (state: State) => state.items
// somewhere in the store subscription:
const items = optimizeImmutableUpdate(prevItems, selectItems(state))
// `items === prevItems` whenever the content is identicalThe package ships two hooks behind the optimize-immutable-update/react subpath. React is an optional peer dependency: this package never installs React by itself, and the hooks use whichever React (>= 16.8) your project already has.
import { useOptimizedMemo, useOptimizedState } from 'optimize-immutable-update/react'A drop-in useState that runs every update through the optimizer:
const [state, setState] = useOptimizedState({ user: { name: 'Ann' }, cart: { total: 100 } })
setState({ user: { name: 'Ann' }, cart: { total: 150 } })
// re-render happens, but state.user is the very same object as before
setState((prev) => ({ ...prev, cart: { total: prev.cart.total } }))
// deep-equal result: the updater returns the previous reference
// and React bails out of the re-render entirelySame semantics as useState (value and functional updates, lazy initializer), plus:
- untouched subtrees keep their references across updates,
- updates that change nothing skip the re-render — React's own bail-out on an unchanged reference, working for objects and primitives alike (
NaNincluded), - batched sequential updates always compare against the latest queued state, never a stale render snapshot.
A useMemo that stabilizes its result reference:
const todos = useOptimizedMemo(() => buildExpensiveView(state), [state])
// when `state` changes but the built view is deep-equal,
// `todos` keeps the previous reference → memoized rows and effect deps stay quietWhen deps change, the factory re-runs as usual. If the new result is deep-equal to the previous one, the old reference is returned, so downstream memo components and useEffect dependencies see no change. Array items are matched by id, so reorders don't invalidate rows.
The suite lives in benchmark/ and compares the direct analogs on deterministic seeded trees:
npm run benchEvery timed operation includes a structuredClone of next — this library mutates next in place, so a fresh input is required per op; the identical clone cost is added to every participant to keep the comparison uniform.
gattai-merge is timed in a deep-array configuration — the built-in arrays: 'merge' for trees plus the merge-by-id array function from its own README for id-keyed lists. Its default arrays: 'replace' never deep-compares arrays (a plain slice()), so it does strictly less work than every sharing participant and is not a comparable workload.
Snapshot (2026-09-04, Node v24.19.0, Apple M2 Max; ops/sec, median):
| Scenario | optimize-immutable-update | replaceEqualDeep | gattai-merge | emerge | deepmerge | dequal+keep-prev |
|---|---|---|---|---|---|---|
| tiny tree (69 nodes) / unchanged | 23 211 | 22 792 (−1.8%) | 26 403 (+13.7%) | 23 739 (+2.3%) | 9 848 (−57.6%) | 28 402 (+22.4%) |
| tiny tree / one leaf changed | 23 279 | 22 835 (−1.9%) | 26 316 (+13.0%) | 23 599 (+1.4%) | 9 784 (−58.0%) | 28 336 (+21.7%) |
| mid tree (4.2k nodes) / unchanged | 340 | 342 (+0.6%) | 383 (+12.6%) | 348 (+2.4%) | 137 (−59.7%) | 413 (+21.5%) |
| mid tree / one leaf changed | 342 | 339 (−0.9%) | 381 (+11.4%) | 344 (+0.6%) | 138 (−59.6%) | 417 (+21.9%) |
| mid tree / half leaves changed | 491 | 335 (−31.8%) | 321 (−34.6%) | 335 (−31.8%) | 137 (−72.1%) | 539 (+9.8%) |
| big tree (44k nodes) / one leaf changed | 32 | 32 (+0.0%) | 36 (+12.5%) | 32 (+0.0%) | 12 (−62.5%) | 48 (+50.0%) |
| id array (1k items) / same order | 871 | 831 (−4.6%) | 847 (−2.8%) | 896 (+2.9%) | 452 (−48.1%) | 1 028 (+18.0%) |
| id array (1k items) / reversed | 808 | 850 (+5.2%) | 838 (+3.7%) | 897 (+11.0%) | 453 (−43.9%) | 1 310 (+62.1%) |
Deltas are relative to optimize-immutable-update (positive — faster).
Reading notes:
- On identical workloads
optimizeImmutableUpdateperforms on par with TanStack'sreplaceEqualDeep, and ahead of it when half the leaves change — it reusesnextin place instead of building fresh copies. gattai-mergewith the comparable deep-array workload lands within −35%…+14% of this library — faster only on object-heavy trees, slower where mass changes or id-keyed lists dominate. Its defaultarrays: 'replace'is faster still, but never deep-compares arrays: less compare work, less reuse delivered.- In the reversed-array scenario all sharing libraries take comparable time, but only this library returns all 1 000 item references from
prev—replaceEqualDeep's positional compare invalidates every row downstream. dequal + keep-previs the cheap root-only pattern: it returns eitherprevornextwholesale and never stabilizes deep subtrees.- Microbenchmarks measure the merge pass only — not the re-renders, selector recomputations and allocations each reused reference saves downstream. Run your own numbers before drawing conclusions.
npm install # install dependencies
npm run ts-check # TypeScript check
npm run lint:ci # ESLint (flat config)
npm run test # Jest with coverage (100%)
npm run build # Build to ./dist (CJS + declarations)
npm run bench # Benchmarks against the alternatives
npm run release # Publish via np- How to get altered tree from immutable tree, maximising reuse of nodes
- Performance of programs using immutable objects
Copyright (c) 2018-present Dmitry Karpunin koderfunk@gmail.com