Published on

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

Authors
  • avatar
    Name
    Jack Qin
    Twitter

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 (or enabled: someFlag where someFlag === false);
  • a conditional query key that depends on an id not yet resolved;
  • queryFn returned undefined (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: false keeps data persistently undefined (Mechanism 1's trigger) → data ?? [] builds a new array every frame, violating reference stability (Mechanism 1) → the consumer's useEffect([rangers]) fires every frame (Mechanism 2) → the effect's setState triggers a re-render → back to Mechanism 1 → RN amplifies the loop into Maximum 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 hookStability 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 / recordKeep the same reference across renders when the fields haven't changed
null / undefinedUse 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:

CaseAssertion
enabled: false (siteId === null) — render twiceThe two returned arrays are Object.is-equal
Query resolved, input unchanged — render twiceThe two arrays are Object.is-equal (TanStack Query default behavior)
Query resolves with new server dataThe 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