Query states: pending, fetching, stale and gone
- Tell
status(is there data?) fromfetchStatus(is thequeryFnrunning?), and predictisPending,isFetchingandisLoadingat first load, during a background refetch, and for a disabled or offline query - Order a query's render checks so a background refetch or a failed refetch never blanks data already on screen, and an empty state never shows before data has loaded
- Keep the previous page on screen during a key change with
placeholderData: keepPreviousData, and useisPlaceholderDatato guard actions - Choose
staleTimeorgcTimefor a symptom, and name what refetches stale data by default (mount, window focus, reconnect) and how failed queries retry (3 times, with backoff)
Read
Two questions — "do we have data?" and "is it fetching?"
FlagA useQuery result answers two independent questions, and most loading bugs come from
reading one when you meant the other.
| Field | Question | Values |
|---|---|---|
status | Do we have data? | pending (none yet) · error · success |
fetchStatus | Is the queryFn running right now? | fetching · paused (offline) · idle |
Every combination happens. A list that's already on screen and refetching in the background
is success and fetching. A query waiting on a condition (enabled: false) with no data
is pending and idle — nothing loaded, nothing loading.
The booleans you'll actually write are shorthands for those fields:
isPending—status === "pending": no data yet.isFetching—fetchStatus === "fetching": a request is in flight, first load or background refetch alike.isLoading—isPending && isFetching: the first load is in flight.
Here's one query over its life, as TanStack Query v5 reports it:
| Moment | status | fetchStatus | isPending | isFetching | isLoading |
|---|---|---|---|---|---|
| First render, no cache | pending | fetching | true | true | true |
| Data arrived | success | idle | false | false | false |
| Background refetch | success | fetching | false | true | false |
| Disabled, no data | pending | idle | true | false | false |
So the rule for a full-screen spinner is "no data yet" — isPending — and isFetching
is for a small "refreshing" hint next to data you keep showing:
const { data, isPending, isFetching } = useQuery({ queryKey: ["todos"], queryFn: fetchTodos });
if (isPending) return <Spinner />; // nothing to show yet
return (
<>
{isFetching && <small>Refreshing…</small>} {/* data stays on screen */}
<TodoTable rows={data} />
</>
);
Gate the whole screen on isFetching instead and every background refetch — a refocused
tab, a reconnect, an invalidation after a save — wipes a perfectly good table.
Rendering one query through its whole life
FlagA paged list has to render five situations correctly: first load, data, background refetch, failed refetch, and page change. The order of the checks decides which of them work.
import { keepPreviousData, useQuery } from "@tanstack/react-query";
function TodoList() {
const [page, setPage] = useState(1);
const { data, error, isError, isPending, isFetching, isPlaceholderData, refetch } = useQuery({
queryKey: ["todos", { page }],
queryFn: () => fetchTodoPage(page),
placeholderData: keepPreviousData,
});
if (isPending) return <Spinner />;
if (data === undefined) return <ErrorPanel message={error?.message} />;
return (
<>
<button onClick={() => refetch()}>Refresh</button>
{isFetching && <small>Refreshing…</small>}
{isError && <Banner>Couldn't refresh: {error.message}</Banner>}
{data.items.length === 0 ? <p>No todos.</p> : <Table rows={data.items} dimmed={isPlaceholderData} />}
<button disabled={isPlaceholderData || !data.hasMore} onClick={() => setPage((p) => p + 1)}>
Next
</button>
</>
);
}
Line by line:
if (isPending)first. Pending means no data, so it's the only state that earns the full-screen spinner. A background refetch issuccess+ fetching and never reaches it.if (data === undefined)second. PastisPending, missing data means this key's first load failed — the first page, or a page change (see below the table): show the error panel, with a retry and a way back. The temptingif (isError)here is a bug: when a refetch fails,statusbecomes"error"butdatakeeps the last good result. CheckingisErrorfirst throws a perfectly good list away because one refresh failed.- Error and data → a banner. Below the gates,
isErrorcan only mean "a refetch failed". (isRefetchErrorsays exactly that, if you prefer the explicit flag.) - The empty state lives after the gates.
data.items.length === 0is only "nothing here" once data exists. Written as(data?.items ?? []).length === 0before the pending check, the user sees "No todos." flash during every first load. placeholderData: keepPreviousData. Each page is a new key, so a new entry with no data — without this,Nextdrops back topendingand the spinner. With it, the query reportssuccess,datais the previous page, andisPlaceholderDataistrueuntil page 2 lands. The placeholder is never written into page 2's cache entry.disabled={isPlaceholderData || …}. While showing page 1 as a placeholder,hasMoreis page 1's answer, not page 2's — don't let the user click ahead on stale information.
Run against the real library (v5.104), the page change reads like this, render by render:
| Render | status | fetchStatus | isPlaceholderData | data |
|---|---|---|---|---|
| page 1 loaded | success | idle | false | page 1 |
| clicked Next | success | fetching | true | page 1 |
| page 2 arrived | success | idle | false | page 2 |
Without keepPreviousData the middle row is pending / fetching with data: undefined. And
if page 2's fetch fails, the last row is error / idle / false / undefined: the placeholder
lasts only while the new key is pending, so page 1 is gone and the error panel shows.
States that surprise in production
FlagThe table in the simple tier covers the happy path. These are the combinations that ship as bugs, each checked against TanStack Query v5.104.
The spinner that never stops: a disabled query
const { data, isPending } = useQuery({
queryKey: ["orders", { customerId }],
queryFn: () => fetchOrders(customerId!),
enabled: customerId !== null, // wait until a customer is picked
});
if (isPending) return <Spinner />; // ❌ spins forever while no customer is picked
A disabled query with no data is pending + idle: no data, and nothing fetching. isPending
is true until a customer is chosen, so the spinner never stops. Two honest renders: show the
"pick a customer" state when the query is disabled, and use isLoading (isPending && isFetching) for the spinner — it's true only while a first load is actually running. (The
docs' "lazy queries" section recommends exactly this.)
Pending, but paused: offline
With the default networkMode: "online", a query that mounts offline doesn't call its
queryFn at all. It sits at pending + paused (isPaused true), so both isFetching
and isLoading are false. A screen that shows its spinner on isLoading and its empty state
otherwise tells an offline user they have no orders. Check isPaused (or fetchStatus === "paused") and say "You're offline". When the connection returns, the fetch continues by
itself.
Errors that take seven seconds
On the client, a failing query is retried 3 times with 1 s, 2 s and 4 s delays before status
becomes error. Two consequences:
- Tests time out. A test that expects an error message waits ~7 s. The testing guide's
answer is a test
QueryClientwithretry: false, created fresh per test. - Retrying a 404 is pointless.
retryaccepts a function:retry: (failureCount, error) => !isNotFound(error) && failureCount < 3. MeanwhilefailureCountandfailureReasonlet you show "retrying…" honestly.
"It refetches when I switch tabs"
With the default staleTime: 0, every return to the tab refetches every mounted query. Since
v5 the trigger is visibilitychange (the tab becoming visible), not the window focus event,
so clicking between two side-by-side windows doesn't refetch. If the refetches are too
frequent, raise staleTime for that data; turning refetchOnWindowFocus off everywhere hides
the symptom and keeps stale data on screen.
gcTime: 0 turns navigation into hard loads
A query with no mounted users is removed after gcTime. With the default 5 minutes, going back
to a page shows its data instantly and refetches in the background. Set gcTime: 0 (to "save
memory") and every return trip is pending and a spinner again.
Quiz
A list loaded a minute ago (default staleTime: 0). The user switches back to the tab and a background refetch starts. Which flags are true during that refetch?
useQuery({ queryKey: ['orders', { customerId }], queryFn: () => fetchOrders(customerId!), enabled: customerId !== null }), rendered with if (isPending) return <Spinner />. What shows before a customer is picked?
A component renders if (isPending) return <Spinner />; if (isError) return <ErrorPanel />; return <Table rows={data} />. After a successful load, a background refetch keeps failing. With default options, what does the user see?
const rows = data ?? []; if (rows.length === 0) return <p>No orders yet.</p>; if (isPending) return <Spinner />; What's the bug?
Every time a user switches back to the dashboard tab, its 12 queries refetch. The data changes about once an hour. What's the best fix?
FlagDefault options. A user leaves the Orders page — its only query user unmounts — and comes back 2 minutes later. What happens?
FlagA paged query uses placeholderData: keepPreviousData. The user clicks Next on page 1. Before page 2 arrives, what does the query report?
A query is mounted and its data is stale (default options, in the browser). Which of these make it refetch on their own? Pick all that apply.
FlagFill in the blank
Gate the full-screen spinner on "no data yet", show a small hint during any fetch, and a banner when a refetch failed but data remains.
Flagif () return <Spinner />; if (data === undefined) return <ErrorPanel error={error} />; return ( <> { && <small>Refreshing…</small>} { && <Banner>Couldn't refresh — showing the last loaded list</Banner>} <Table rows={data} /> </> );
Keep page N on screen while page N+1 loads, and don't let the user click ahead on page N's hasMore.
const { data, isPlaceholderData } = useQuery({ queryKey: ["projects", { page }], queryFn: () => fetchProjects(page), placeholderData: , }); <button disabled={ || !data?.hasMore}>Next</button>
Settings change rarely: serve them from the cache for 5 minutes without refetching, and keep the unused entry for 30 minutes after the last user unmounts.
FlaguseQuery({ queryKey: ["settings"], queryFn: fetchSettings, : 5 * 60_000, : 30 * 60_000, });
This query is disabled until the user picks a customer. Show the spinner only while a first load is actually running.
Flagconst { data, } = useQuery({ ...ordersQuery(customerId), enabled: customerId !== null }); if () return <Spinner />;
Predict the output
// QueryClient with retry: false.
// The first fetch for ["todos"] resolved with ["a"].
// Then queryClient.invalidateQueries({ queryKey: ["todos"] }) runs,
// and that refetch rejects with new Error("boom").
const { status, data, error, isRefetchError } = useQuery({ queryKey: ["todos"], queryFn });
console.log(status, JSON.stringify(data), error?.message, isRefetchError);const { isPending, isFetching, isLoading } = useQuery({
queryKey: ["report", { month }],
queryFn: () => fetchReport(month),
enabled: false,
});
console.log(isPending, isFetching, isLoading);
// Nothing is cached for this key yet.// The browser is offline (default networkMode: "online"). Nothing is cached for ["inbox"].
const { status, fetchStatus, isLoading, isPaused } = useQuery({
queryKey: ["inbox"],
queryFn: fetchInbox,
});
console.log(status, fetchStatus, isLoading, isPaused);let calls = 0;
const { status, failureCount } = useQuery({
queryKey: ["stats"],
queryFn: async () => { calls++; throw new Error("500"); },
// default retry and retryDelay, in the browser
});
// Wait until status is "error".Challenge
Stop the list from blanking on every fetch
FlagTodoList shows a paged todo list with a Refresh button. Users complain that it flickers:
- Every Refresh wipes the list and shows Loading todos…, although the old list was perfectly good while the new one loaded.
- When a refresh fails, the list disappears behind an error message — the last good data is thrown away.
- Next blanks the list too, and the user loses their place until the next page arrives.
Make the list behave like a cache: show the loading screen only when there is nothing to show yet, keep data on screen through background fetches and failed refetches, and keep the current page visible while the next one loads.
The tests render TodoList with their own QueryClient (retries off) and a fake server they
can pause or make fail through api.ts.
Your task
Edit TodoList.tsx so the tests pass:
- The first load shows a loading state (not No todos), then the list
- A background refetch (Refresh) keeps the list on screen and shows Refreshing…
- A failed refetch keeps the list on screen and adds an error banner
- A failed first load shows the error instead of a list
- Next keeps the current page on screen until the next page arrives, and is disabled meanwhile
- No todos appears only once an empty list has loaded
Further reading
- TanStack Query v5 docs: Queries
- TanStack Query v5 docs: Important Defaults
- TanStack Query v5 docs: Paginated / Lagged Queries
- TanStack Query v5 docs: Disabling/Pausing Queries
- TanStack Query v5 docs: Query Retries
- TanStack Query v5 docs: Network Mode
- TanStack Query v5 docs: Window Focus Refetching
- TanStack Query docs: Migrating to TanStack Query v5