Data Fetching with TanStack
Data Fetching with TanStack

Query states: pending, fetching, stale and gone

Full · ~35 min
After this unit you can
  • Tell status (is there data?) from fetchStatus (is the queryFn running?), and predict isPending, isFetching and isLoading at 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 use isPlaceholderData to guard actions
  • Choose staleTime or gcTime for a symptom, and name what refetches stale data by default (mount, window focus, reconnect) and how failed queries retry (3 times, with backoff)

Read

Simple
1 / 2

Two questions — "do we have data?" and "is it fetching?"

Flag

A useQuery result answers two independent questions, and most loading bugs come from reading one when you meant the other.

FieldQuestionValues
statusDo we have data?pending (none yet) · error · success
fetchStatusIs 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:

MomentstatusfetchStatusisPendingisFetchingisLoading
First render, no cachependingfetchingtruetruetrue
Data arrivedsuccessidlefalsefalsefalse
Background refetchsuccessfetchingfalsetruefalse
Disabled, no datapendingidletruefalsefalse

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.

Medium

Rendering one query through its whole life

Flag

A 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 is success + fetching and never reaches it.
  • if (data === undefined) second. Past isPending, 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 tempting if (isError) here is a bug: when a refetch fails, status becomes "error" but data keeps the last good result. Checking isError first throws a perfectly good list away because one refresh failed.
  • Error and data → a banner. Below the gates, isError can only mean "a refetch failed". (isRefetchError says exactly that, if you prefer the explicit flag.)
  • The empty state lives after the gates. data.items.length === 0 is only "nothing here" once data exists. Written as (data?.items ?? []).length === 0 before 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, Next drops back to pending and the spinner. With it, the query reports success, data is the previous page, and isPlaceholderData is true until page 2 lands. The placeholder is never written into page 2's cache entry.
  • disabled={isPlaceholderData || …}. While showing page 1 as a placeholder, hasMore is 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:

RenderstatusfetchStatusisPlaceholderDatadata
page 1 loadedsuccessidlefalsepage 1
clicked Nextsuccessfetchingtruepage 1
page 2 arrivedsuccessidlefalsepage 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.

Complex

States that surprise in production

Flag

The 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 QueryClient with retry: false, created fresh per test.
  • Retrying a 404 is pointless. retry accepts a function: retry: (failureCount, error) => !isNotFound(error) && failureCount < 3. Meanwhile failureCount and failureReason let 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?

Flag

useQuery({ queryKey: ['orders', { customerId }], queryFn: () => fetchOrders(customerId!), enabled: customerId !== null }), rendered with if (isPending) return <Spinner />. What shows before a customer is picked?

Flag

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?

Flag

const rows = data ?? []; if (rows.length === 0) return <p>No orders yet.</p>; if (isPending) return <Spinner />; What's the bug?

Flag

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?

Flag

Default options. A user leaves the Orders page — its only query user unmounts — and comes back 2 minutes later. What happens?

Flag

A paged query uses placeholderData: keepPreviousData. The user clicks Next on page 1. Before page 2 arrives, what does the query report?

Flag

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.

Flag

Fill 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.

Flag
if () 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.

Flag
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.

Flag
useQuery({
  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.

Flag
const { 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);
Flag
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.
Flag
// 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);
Flag
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".
Flag

Challenge

Stop the list from blanking on every fetch

Flag

TodoList 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

Loading the in-browser editor…

Further reading