Structural Sharing & Referential Stability
When TanStack Query v5 receives a fresh response, it does not simply hand you the new object. By default it runs structuralSharing â a deep walk (replaceEqualDeep) that compares the incoming data against the previous cached value and reuses the old reference for every branch that is deeply equal. Only the nodes that actually changed get new object identities. That single behavior is why unchanged list items skip re-rendering, why useMemo dependencies stay stable, and why a background refetch of identical data costs nothing on screen. This page explains the mechanism and when to override it, and it lives inside Reference vs Value Storage Models. If your data forms cycles that break the deep walk, the sibling guide on handling circular references in cache covers the failure mode this page’s default assumes away.
Referential stability is the whole reason to care. React bails out of re-rendering a child when its props are referentially identical (Object.is), and hooks like useMemo, useEffect, and useCallback fire only when a dependency reference changes. Structural sharing turns “the server sent the same data” into “React sees the same object,” which is the difference between a silent refetch and a full subtree re-render.
What replaceEqualDeep Actually Does
replaceEqualDeep(prev, next) recurses both values in parallel. For each node it asks: are these deeply equal? If yes, it returns prev â the old reference â discarding next entirely for that branch. If no, it returns a new object/array but still reuses any unchanged children inside it. The result is a new tree only along the paths that changed, sharing structure with the old tree everywhere else. This is the same copy-on-write idea behind persistent data structures, applied at cache-write time.
Step-by-Step: Working With Structural Sharing
Step 1 â Confirm it is preserving references
Structural sharing is on by default; the first job is verifying it works for your data. A quick assertion in a test or effect proves that identical refetches produce identical references.
import { replaceEqualDeep } from '@tanstack/react-query';
const prev = { user: { id: '1', name: 'Ada' }, tags: ['a', 'b'] };
const next = { user: { id: '1', name: 'Ada' }, tags: ['a', 'b'] };
const merged = replaceEqualDeep(prev, next);
console.log(merged === prev); // true â whole tree deeply equal
console.log(merged.user === prev.user); // true â unchanged branch reused
Cache Behavior Analysis. replaceEqualDeep returns prev itself when the entire tree is deeply equal, so a background refetch of unchanged data yields the exact same top-level reference. Because useQuery compares that reference, the component does not re-render at all â the refetch is invisible to React even though a network round-trip occurred.
Step 2 â Stabilize derived data with select
The select option transforms cached data before it reaches your component. Structural sharing is applied to the select output too, but only if the function returns JSON-comparable data and is referentially stable itself. Hoist or memoize it.
import { useQuery } from '@tanstack/react-query';
interface Post { id: string; title: string; archived: boolean }
// Hoisted so it is not recreated per render
const selectActiveTitles = (posts: Post[]) =>
posts.filter((p) => !p.archived).map((p) => p.title);
function ActiveTitles() {
const { data } = useQuery({
queryKey: ['posts'],
queryFn: (): Promise<Post[]> => fetch('/api/posts').then((r) => r.json()),
select: selectActiveTitles,
staleTime: 30_000,
});
return <ul>{data?.map((t) => <li key={t}>{t}</li>)}</ul>;
}
Cache Behavior Analysis. TanStack Query runs select, then applies replaceEqualDeep to its result against the previous select output. So even though filter().map() builds a brand-new array each call, the deep-equal merge collapses it back to the prior reference when the derived titles are unchanged â the component sees a stable array and skips re-rendering. This only holds because selectActiveTitles is hoisted; an inline arrow would still be stable input-wise, but hoisting avoids the function identity churn that defeats other memoization around it.
Step 3 â Disable or customize for large or non-JSON payloads
The deep walk is O(n) in node count. For huge payloads that change wholesale every fetch, or for values replaceEqualDeep cannot safely traverse, override it.
import { useQuery } from '@tanstack/react-query';
// A: disable entirely for a massive payload that always changes
function useHugeReport() {
return useQuery({
queryKey: ['report', 'huge'],
queryFn: fetchReport,
structuralSharing: false, // skip the deep walk; always take the new reference
});
}
// B: custom fn that only compares by a version field, cheaply
function useVersionedDoc() {
return useQuery({
queryKey: ['doc'],
queryFn: fetchDoc,
structuralSharing: (oldData, newData) => {
const prev = oldData as { version: number } | undefined;
const next = newData as { version: number };
return prev && prev.version === next.version ? prev : next;
},
});
}
Cache Behavior Analysis. With structuralSharing: false, TanStack Query stores the new reference verbatim â no comparison cost, but every consumer re-renders on every fetch. The custom function replaces the deep walk with a cheap version check: if versions match it returns the old reference (full bail-out) and otherwise the whole new object, trading fine-grained sharing for a single O(1) comparison â ideal when the server already stamps a monotonic version.
- Keep
structuralSharing: true(default) for typical JSON lists â it is the cheapest path to referential stability. - Set
structuralSharing: falsefor payloads that change wholesale or exceed tens of thousands of nodes where the walk dominates. - Provide a custom
structuralSharingfn when aversion/updatedAtfield lets you decide equality in O(1) instead of O(n). - Never rely on
replaceEqualDeepforMap,Set,Date, or class instances â it only traverses plain objects and arrays.
Edge Cases
Non-JSON values. replaceEqualDeep walks plain objects and arrays only. A Map, Set, Date, or class instance is treated as a leaf and compared by reference, so it always looks changed and defeats sharing â disable structural sharing or serialize such values into plain data first. select memoization. A select that returns a new object literal wrapping primitives ({ count: data.length }) benefits from the output-side deep merge, but a select returning a class instance or closure does not â keep select outputs JSON-shaped. Huge unchanged payloads. If the data is large and usually unchanged, the default is ideal; if large and usually changed, the walk is wasted work â measure before disabling, because losing referential stability can cost more in re-renders than the walk saved.
Common Pitfalls and Resolutions
| Observable Issue | Root Cause | Diagnostic Resolution |
|---|---|---|
| Every child re-renders after a background refetch of identical data | structuralSharing: false was set, or the payload contains a Map/Date treated as a leaf so the top reference always changes |
Re-enable default structural sharing; convert non-JSON values to plain objects/arrays before caching so replaceEqualDeep can traverse them |
A memoized select still emits a new reference each render |
The select function is recreated inline every render, or it returns a non-JSON shape the output-side deep merge cannot compare |
Hoist the select function and return plain arrays/objects so replaceEqualDeep can collapse unchanged output to the prior reference |
| Deep, mostly-unchanged payload makes fetches feel slow | The O(n) replaceEqualDeep walk dominates when node count is huge |
Supply a custom structuralSharing comparing a version field in O(1), or set structuralSharing: false if consumers memoize downstream anyway |
Frequently Asked Questions
When should I set structuralSharing to false?
Disable it when the deep-equality walk costs more than the re-renders it prevents â very large payloads (tens of thousands of nodes) that change wholesale on every fetch, or data whose consumers already memoize downstream. Also disable it for non-JSON values like class instances, Map, or Set, which replaceEqualDeep cannot traverse and will treat as always-changed leaves, silently defeating the optimization anyway.
Why does my select still return a new reference every render?
structuralSharing stabilizes the raw query data, and TanStack Query re-applies it to the select output too â but only when that output is JSON-serializable. A select returning a class instance, a closure, or a Map cannot be deep-compared, so it yields a fresh reference each time. Return plain arrays and objects, and hoist the select function so its identity does not churn.
How does Apollo's approach to referential stability differ?
Apollo’s InMemoryCache returns frozen, canonicalized result objects and reuses the same reference for identical query results via result caching, so stability comes from normalization rather than a deep-equal merge on each write. Immer, used inside RTK and RTK Query, produces new references only for the branches you touch through its draft â structural sharing by construction rather than by post-hoc comparison.
Related
- Reference vs Value Storage Models â the parent reference on when caches store shared references versus copied values, which is what structural sharing preserves.
- Handling Circular References in Cache â the failure mode when data forms cycles that the
replaceEqualDeepwalk on this page cannot safely traverse. - Deriving Filtered Views from Normalized Cache â how memoized
selecttransforms stay referentially stable so derived lists do not re-render, building directly on structural sharing. - State Architecture & Cache Fundamentals â the foundational pillar linking storage models, normalization, and re-render performance across cache libraries.