- Published on
Reference Stability Is a Hook's Implicit Contract: Why `data ?? []` Becomes an Infinite Loop on Mobile
- Authors

- Name
- Jack Qin
React's dependency tracking is built on a plain but far-reaching decision: it uses reference equality (Object.is), not value equality, to decide "did the dependency change." The dependency arrays of useEffect, useMemo, and useCallback all follow this semantic. The choice is reasonable — value equality is expensive and undecidable for arbitrary objects, while reference equality is O(1). But it quietly pushes one responsibility onto the developer: any value that goes into a dependency array must keep the same reference whenever the underlying data hasn't changed.
This responsibility is usually carried silently by various libraries (including TanStack Query's structural sharing), so we almost never think about it explicitly. Until a seemingly innocent data ?? [] shows up in a custom hook's return statement — it constructs a fresh empty array on every render, violating the contract. This post isn't about that one bug; it's about the mechanism behind it: the reference-equality dependency semantic, the evaluation timing of empty literals, and the fundamental difference between RN and the web in the consequences of "violating the contract." Understand these three layers and you can predict this class of hook, rather than wait for the infinite loop to blow up in your face.
Mechanism 1: A right operand that's evaluated on every render
First, the scene of the violation:
// Hook
export function useSensorsQuery(siteId: string | null) {
const { data, isLoading, isError } = useQuery(/* … */)
return {
rangers: data ?? [], // ← a brand-new array on every render
isLoading,
isError,
}
}
?? is an ordinary binary operator — it evaluates its right operand when the left operand is null/undefined. And [] is an array literal expression; every evaluation constructs a new array object. So when data is undefined, data ?? [] produces a fresh [] on every render: both are empty arrays, equal by value, but Object.is judges them unequal.
When is data persistently undefined? Precisely in these "windows" — the query is disabled, still loading, or hasn't started:
enabled: false(orenabled: someFlagwheresomeFlag === false);- a conditional query key that depends on an id not yet resolved;
queryFnreturnedundefined(it shouldn't, but it does happen).
In this example the trigger is siteId === null → enabled: false → data is forever undefined. So rangers is a new reference on every frame for this screen's entire lifetime.
Mechanism 2: A new reference fed to a dependency array = an effect that always fires
Now connect the consumer:
useEffect(() => {
setSelectedAssetIds(rangers.map((r) => r.assetId))
}, [rangers]) // rangers changes reference every frame → effect fires every frame → setState → re-render → another new [] → …
Because rangers is a new reference every frame, the dependency array [rangers] looks to React like it changed every frame, so the effect re-runs every frame. And this effect calls setSelectedAssetIds — a setState. The setState triggers a parent re-render, the re-render re-runs the hook, the hook spits out a fresh [] again, the effect fires again… the loop is closed.
A detail that easily gets blamed unfairly: TanStack Query's own data field is reference-stable by default (it does structural sharing internally and returns the same reference when the data hasn't changed). The bug isn't in the query — it's in the wrapping hook's return statement, where the fallback is almost always hand-added on that one line. The query handed stability to you, and you threw it away with a literal.
If a hook's return type advertises T[], the caller will reasonably assume it can put it in a dependency array. That's what "implicit contract" means: the type signature promises T[], but the reference-stability assumption the caller makes off that promise is something the hook must also honor.
Mechanism 3: Why the web is merely wasteful but mobile is fatal
The same violation pattern has consequences of different magnitudes on the web versus RN. On the web, the extra render per frame is usually just waste — the browser's render scheduling and React's batching tend to smooth it out, so it looks like "a stutter" at worst and rarely forms a visible infinite loop.
RN is different. Its setState triggers a re-render, the re-render feeds the next round of effects, and the whole chain spins on the JS thread at a furious pace until it slams into React's loop guard:
ERROR Maximum update depth exceeded. This can happen when a component
calls setState inside useEffect, but useEffect either doesn't have a
dependency array, or one of the dependencies changes on every render.
This difference is itself a transferable reminder: the same reference-stability violation has a different "amplification factor" in different runtimes. The web's tolerance lets this class of bug lie dormant for a long time, and once the same code lands on mobile, the latent contract violation is amplified into a hard crash.
Where the three mechanisms meet
Stacked up, the infinite loop is a structural inevitability:
enabled: falsekeepsdatapersistentlyundefined(Mechanism 1's trigger) →data ?? []builds a new array every frame, violating reference stability (Mechanism 1) → the consumer'suseEffect([rangers])fires every frame (Mechanism 2) → the effect'ssetStatetriggers a re-render → back to Mechanism 1 → RN amplifies the loop intoMaximum update depth exceeded(Mechanism 3).
Each of the three things, viewed alone, is not "wrong": the ?? fallback is a routine idiom; putting an array in a dependency array is routine usage; RN's setState-drives-re-render is its core model. What's wrong is the violated implicit premise at their intersection — a value that goes into a dependency array must be reference-stable.
The fix, and the contract it forces out
Preferred: a module-level constant
const EMPTY_SENSORS: SensorOption[] = []
export function useSensorsQuery(siteId: string | null) {
const { data, isLoading, isError } = useQuery(/* … */)
return {
rangers: data ?? EMPTY_SENSORS, // ← reference-stable
isLoading,
isError,
}
}
A module-level constant is the same reference for the entire process lifetime — zero extra allocation, no spurious renders. First choice on a hot path.
When the fallback depends on render-time input: useMemo
const rangers = useMemo(() => data ?? EMPTY_SENSORS, [data])
Only write this when the fallback itself depends on render-time input; otherwise data ?? EMPTY_SENSORS is enough and saves the bookkeeping overhead of memo.
The fix itself is one line, but it forces out a stability contract broken down by field type — and that's the reusable part:
| Field type returned by the hook | Stability requirement |
|---|---|
Primitive (string / number / boolean) | N/A — value equality is structural equality |
Array T[] | Keep the same reference across renders when the dataset hasn't changed |
| Object / record | Keep the same reference across renders when the fields haven't changed |
null / undefined | Use the singleton — never a freshly constructed empty placeholder |
This contract must become a regression guard. Beyond asserting correct values, the mobile wrapping hook should assert reference stability across renders. Since this project's renderHook is unavailable, the practical approach is to extract the decision logic into a pure function and test that:
| Case | Assertion |
|---|---|
enabled: false (siteId === null) — render twice | The two returned arrays are Object.is-equal |
| Query resolved, input unchanged — render twice | The two arrays are Object.is-equal (TanStack Query default behavior) |
| Query resolves with new server data | The reference changes (so consumers react) |
The first case pins exactly the bug that prompted this post; the third is just as important — stability is not "always the same reference" but "change only when the data changes," otherwise consumers go deaf to real updates.
The transferable layer
Set aside React and TanStack Query's specific APIs, and the truly transferable lesson is:
A system that does change detection via reference equality turns "reference stability" into an implicit contract that must be honored at every return point. React's dependency array is just the most common carrier; any mechanism that "judges dirty/clean by reference" (memoization, React.memo, selector comparison, a virtual list's key diff) shares this contract. The cheapest way to violate it is to use a temporary literal (?? [], ?? {}, an inline () => {}) as a fallback at the return point.
When debugging this kind of problem, instead of chasing "who's calling setState," ask it the other way around: is this value that went into the dependency array the same reference when the underlying data hasn't changed? It's also worth scanning the same directory for the same class of pattern — for instance, a default-parameter destructure like data: allSites = [] is the same shape of latent bug, and it isn't looping today only because no consumer is yet listening to it inside a setState-firing effect.
rg -nP "(data|items|list|rows)\s*[?]{2}\s*\[\s*\]" apps/mobile/src
rg -nP ":\s*[A-Z][A-Za-z]*\[\]\s*=\s*\[\s*\]" apps/mobile/src