skip to content

With TanStack Query v5 wired to AppState and NetInfo in React Native, how many portfolio requests fire when focus and reconnect events arrive together?

level: middleimportance: should knowfreq 30%

answer

  1. one cache entry per query key
  2. in-flight promise is shared
  3. cancelRefetch: false on focus and reconnect
  4. manual refetch() cancels by default
  5. different keys, different requests

basics

~20 s

One per query key. The focus and reconnect triggers both refetch with cancelRefetch set to false, so the second trigger finds a fetch already in flight and joins its promise instead of sending another request.

solid answer

~50 s

Returning from background often produces an `AppState` change to `active` and a NetInfo reconnect within moments of each other, and both are wired into TanStack Query. Each trigger walks the cache and, per query, picks the first observer that wants a refetch and calls `refetch({ cancelRefetch: false })`. Inside the query, a fetch that is not `idle` is reused: the call returns the in-flight promise. So the portfolio key sends one request, however many components observe it and however many triggers fire. A paused fetch is continued rather than restarted. Two things break the dedup: different query keys for the same data (`['portfolio']` and `['portfolio', accountId]` are two cache entries), and a manual `refetch()` or `refetchQueries`, whose default `cancelRefetch: true` cancels a running fetch on a query that already has data and starts a new one.

code

tsx · 21 lines
tsx
import { Text } from 'react-native';
import { useQuery } from '@tanstack/react-query';

const portfolioQuery = {
  queryKey: ['portfolio'] as const,
  queryFn: async ({ signal }: { signal: AbortSignal }) => {
    const res = await fetch('https://api.example.com/portfolio', { signal });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return (await res.json()) as { totalValue: number; positions: number };
  },
};

export function PortfolioHeader() {
  const { data } = useQuery(portfolioQuery);
  return <Text>{data ? `Total ${data.totalValue}` : 'Loading'}</Text>;
}

export function HoldingsCount() {
  const { data } = useQuery(portfolioQuery); // same key: same cache entry, same request
  return <Text>{data ? `${data.positions} positions` : ''}</Text>;
}

go deeper

for a junior

Recall that every useQuery with the same key shares one cache entry, so the same data is fetched once however many screens show it.

for a middle

Walk through the focus and reconnect handlers, cancelRefetch false, and why a second trigger returns the in-flight promise.

for a senior

Spot the real sources of duplicates in production: mismatched keys, manual refetch defaults, listeners written before the managers, and queryFns that ignore the abort signal.

for a principal

Set conventions, such as key factories and a single place that wires app signals, so the dedup guarantees survive a large codebase.

## Why this happens on a phone in the first place On the web, focus and connectivity changes rarely coincide. On a phone they often do: the user leaves the investing app, the device drops its radio or switches from Wi-Fi to cellular while the app is in the background, and on return two signals arrive almost together: - `AppState` reports `active`, which the app forwards to TanStack Query's `focusManager`; - NetInfo reports a connection, which the app forwards to `onlineManager`. Both managers notify the `QueryClient`, and each notification first resumes paused mutations and then walks every query in the cache. The question interviewers are probing is whether that double walk doubles the traffic. ## Deduplication happens at the cache entry TanStack Query keeps **one `Query` object per query hash**, derived from the query key. Every `useQuery` with the same key, on any screen, is an **observer** of that one object. That gives two layers of dedup: 1. **Per trigger:** the focus and reconnect handlers look for the *first* observer whose options want a refetch (the query is stale and `refetchOnWindowFocus` or `refetchOnReconnect` allows it) and call `refetch` on that observer only. Five mounted components observing `['portfolio']` still produce one call. 2. **Across triggers:** those handlers pass `cancelRefetch: false`. When the second trigger arrives, the query's `fetchStatus` is not `idle`, so its `fetch` method returns the promise of the fetch already running instead of starting another. A fetch that was **paused**, because the device was offline or the app was unfocused, is not restarted either: the handlers call `continue` on the existing retryer, and it picks up where it stopped. ## When you do get more than one request | Cause | Why | What to do | |---|---|---| | Different keys for the same data | two cache entries, two fetches | build keys from one factory so the same data has one key | | Pull-to-refresh calling `refetch()` mid-flight | `cancelRefetch` defaults to `true` for manual refetches when the query has data | accept it, or pass `{ cancelRefetch: false }` | | `refetchQueries` from a listener | also defaults to `cancelRefetch: true` | let the managers drive focus and reconnect instead | | Your own `AppState` listener calling `fetch` | bypasses the cache | route through the query, not around it | Cancelling a running fetch discards its result in the cache, but the HTTP request only stops on the wire if the `queryFn` passes the `signal` it receives to `fetch`. Without that, a cancel-and-restart sends two requests and ignores the first response. ## A worked sequence Take a portfolio query that is stale and has two observers, the header and the holdings list: 1. `AppState` goes `active`; `focusManager` becomes focused. 2. The client resumes paused mutations, then calls `onFocus` on each query; the portfolio query finds the header's observer stale and calls `refetch({ cancelRefetch: false })`. One request starts. 3. Forty milliseconds later NetInfo reports a connection; `onlineManager` becomes online. 4. The portfolio query's `onOnline` finds the same observer stale and calls `refetch({ cancelRefetch: false })`; the fetch is already running, so it returns the same promise. 5. The response lands once and both observers re-render with it. ## What to check when you see duplicates - Log the query hash: two hashes mean two keys, not a dedup failure. - Look for manual `refetch()`, `refetchQueries` or `invalidateQueries` calls in `AppState` or NetInfo listeners you wrote before wiring the managers; those duplicate what the managers already do. - Confirm the `queryFn` forwards the abort `signal`, so the cancellations that do happen actually stop the request.

  • The user pulls to refresh while the foreground refetch is still running. What happens?
    A manual `refetch()` defaults to `cancelRefetch: true`. If the portfolio query already has data, the running fetch is cancelled silently and a new one starts, so its result replaces the old one. If the `queryFn` forwards the abort `signal`, the first request stops on the wire; otherwise it completes and its response is ignored. Pass `{ cancelRefetch: false }` to join the running fetch instead.
  • Why can two screens still fire two portfolio requests on return to foreground?
    Dedup is per query hash. If one screen uses `['portfolio']` and another `['portfolio', accountId]`, they are two cache entries, each with its own stale time and its own fetch. The fix is a shared key factory so the same resource always hashes to the same entry.

saying these in an interview costs you the question

  • Each mounted useQuery sends its own request when the app returns to the foreground
  • Focus and reconnect firing together always double the network traffic
  • A manual refetch() always joins the request already in flight
  • Deduplication compares URLs, so two keys for one endpoint share a request
  • Cancelling a query always aborts the underlying HTTP request