With TanStack Query v5 wired to AppState and NetInfo in React Native, how many portfolio requests fire when focus and reconnect events arrive together?
answer
- one cache entry per query key
- in-flight promise is shared
- cancelRefetch: false on focus and reconnect
- manual refetch() cancels by default
- different keys, different requests
basics
~20 sOne 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 sReturning 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 linesimport { 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
Recall that every useQuery with the same key shares one cache entry, so the same data is fetched once however many screens show it.
Walk through the focus and reconnect handlers, cancelRefetch false, and why a second trigger returns the in-flight promise.
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.
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