A React component subscribes to an external store with useSyncExternalStore, and its getSnapshot is written as `() => ({ items: store.items, total: store.items.length })`. The page freezes and React warns that the result of getSnapshot should be cached. What is going wrong, and how do you fix it?
answer
- identity, not contents, decides re-render
- a new object every call
- Object.is can never bail out
- cache the snapshot on write
- primitives are always safe to return
basics
~20 sgetSnapshot builds a new object on every call, so React's Object.is comparison of consecutive snapshots is never equal. React re-renders, reads again, sees another new object, and loops. Fix it by caching the snapshot object in the store and recomputing it only when the store is written.
solid answer
~50 sReact compares consecutive `getSnapshot` results with `Object.is`. An object literal is a new reference every call, so the comparison always reports a change: React re-renders, calls `getSnapshot` again, gets another fresh object, and re-renders again — an infinite loop, which is what the "result of getSnapshot should be cached" warning is telling you. The fix is to make the snapshot's identity change only when the data changes. Either return a primitive (`store.items.length` on its own), or return a reference the store already holds (`store.items`), or have the store build the derived object once per write and hand out that cached reference on every read. What does *not* fix it is wrapping the call in `useMemo` or adding a dependency array — React calls `getSnapshot` outside the component's render memoization, so the caching has to live in the store or in a memo keyed on the raw store value.
code
javascript · 18 lineslet items = [];
let snapshot = { items, total: 0 };
const listeners = new Set();
export const store = {
subscribe(onStoreChange) {
listeners.add(onStoreChange);
return () => listeners.delete(onStoreChange);
},
getSnapshot() {
return snapshot;
},
add(item) {
items = [...items, item];
snapshot = { items, total: items.length };
listeners.forEach((listener) => listener());
},
};go deeper
Recognise that returning a brand-new object from a function React calls repeatedly is what causes the freeze, and that returning a plain value like a count avoids it.
Explain the comparison rule in one line — consecutive results checked with Object.is — and derive the loop from it rather than quoting the warning message.
Diagnose from the symptom, then give the fix hierarchy: primitive, existing store reference, or a snapshot recomputed on write. Name the mirror-image bug from in-place mutation as well.
Frame it as a store design contract: the store owes subscribers immutable writes and stable derived references, and enforcing that at the store boundary is what stops this class of bug recurring across every consumer.
## The mechanism `useSyncExternalStore(subscribe, getSnapshot)` has one comparison rule: React invokes `getSnapshot`, compares the result to the previous result with `Object.is`, and re-renders if they differ. `Object.is` on two distinct object literals is `false` no matter how identical their contents are — that is reference identity, not structural equality. Now trace the loop: 1. React renders and calls `getSnapshot`, which returns object **A**. 2. React finishes the render and, as a consistency check, reads the snapshot again — object **B**. 3. `Object.is(A, B)` is `false`, so React concludes the store changed under it and schedules another render. 4. Step 1 repeats forever. Nothing about the store actually changed; the *reader* is manufacturing a change on every call. React detects this pattern in development and warns that the result of `getSnapshot` should be cached to avoid an infinite loop, which is a rare case of the framework naming the exact defect. The same trap catches `() => store.items.filter(i => i.active)`, `() => [...store.items]`, `() => new Map(store.entries)` and any `.map()` or object spread inside `getSnapshot`. If the expression allocates, it is a bug. ## The three legitimate fixes ### 1. Return a primitive Primitives compare by value, so identity is a non-issue: ```js const total = useSyncExternalStore(subscribe, () => store.items.length); ``` If a component needs two primitives, call the hook twice. Two subscriptions to one store is cheap and completely safe. ### 2. Return a reference the store already owns ```js const items = useSyncExternalStore(subscribe, () => store.items); ``` This is stable as long as the store treats `items` immutably — replacing the array on write rather than mutating it in place. Mutating in place breaks it in the opposite direction: the reference never changes, so React never re-renders even though the contents did. Immutable updates in the store are the precondition for this hook working at all. ### 3. Cache the derived shape in the store, on write When the component genuinely needs a derived object, compute it where the data changes, not where it is read: ```js let items = []; let snapshot = { items, total: 0 }; const listeners = new Set(); export const store = { subscribe(onStoreChange) { listeners.add(onStoreChange); return () => listeners.delete(onStoreChange); }, getSnapshot() { return snapshot; }, add(item) { items = [...items, item]; snapshot = { items, total: items.length }; listeners.forEach((listener) => listener()); }, }; ``` `getSnapshot` is now a field read. Its result changes identity exactly when a write happened, which is precisely the signal React wants. ## Why the obvious wrong fixes fail - **Wrapping the object in `useMemo` inside the component** does not help on its own, because the loop is driven by the identity that `getSnapshot` returns to React, and React calls that function itself. You can memoize a *getSnapshot function* whose body reads a cached store field, but you cannot memoize your way out of an allocation that happens on every call. - **Adding a dependency array** is not part of this hook's signature; there is no third slot for deps. - **Deep-comparing in a wrapper** does not apply either — React uses `Object.is` and gives you no hook to override the comparison. - **Selecting a slice with a plain inline function** is the usual route into this bug: `() => ({ name: store.user.name })` looks like narrowing and is actually allocating. Return `store.user.name` instead. ## How to talk about it State the comparison rule first (`Object.is` on consecutive snapshots), then the consequence (a fresh allocation is always "changed"), then the fix hierarchy: primitive, existing reference, or store-side caching. Add the immutability corollary — the store must replace values on write, because the same identity check that loops on fresh objects will silently skip re-renders on mutated ones. Candidates who can state both failure directions from one rule are showing they understand the mechanism rather than remembering a warning.
- What is the opposite failure — the store mutating its data in place?Then `getSnapshot` keeps returning the same reference, `Object.is` reports no change, and React never re-renders even though the contents differ. The identity check cuts both ways, which is why the store must replace values on write instead of mutating them.
- Would wrapping the getSnapshot result in useMemo inside the component solve the loop?No. React calls `getSnapshot` itself, outside your render's memoization, so a fresh allocation inside that function still reaches the comparison. Memoization only helps if it is keyed on a stable store value and the function returns the memoized reference — at which point you have moved the cache, which is the real fix.
- A component needs only two fields out of a large store object. What is the cleanest way to subscribe?Call the hook twice, once per field, with each `getSnapshot` returning a primitive. No allocation, no caching problem, and a re-render only when one of those two fields actually changes. Building a combined `{a, b}` object is what creates the identity trap.
saying these in an interview costs you the question
- Says React deep-compares the two snapshot objects
- Blames React 19 concurrency instead of the allocation
- Tries to fix it with a dependency array on the hook
- Wraps getSnapshot in useMemo and stops there
- Mutates the store array in place and expects re-renders