React Query (TanStack Query) is everywhere, and almost everyone is fuzzy on the same three things: why does isLoading stay false during a refetch? what's the difference between "stale" and "garbage-collected"? why does my old data stay on screen while it reloads? So I built an interactive model of the state machine you can poke until it clicks.
▶ Live demo: https://react-query-states.vercel.app/
Source (React 19 + TS, no fetching library): https://github.com/dev48v/react-query-states
The split that explains everything: status vs fetchStatus
TanStack Query tracks fetch state on two independent axes:
-
statusis about the data:pending(no data yet) →success/error. -
fetchStatusis about the request:fetching/idle.
Once you see them as separate, the confusing flags fall out:
isLoading ≈ status === 'pending' → true ONLY on the first load (no data yet)
isFetching = fetchStatus === 'fetching' → true on EVERY fetch, including background refetches
So when you refetch a query that already has data: isFetching flips to true, but isPending stays false — which is why the old data stays on screen with no loading flicker. That's not a bug, it's the whole point. The demo shows both flags live as you click refetch.
Fresh → stale → garbage-collected
Two timers people mix up:
-
staleTime— how long after a successful fetch the data is considered fresh. While fresh, React Query won't refetch on mount/focus. After it, the data is stale and a trigger (mount, refetch, window focus) will revalidate in the background. -
gcTime(formerlycacheTime) — how long the cache survives after the last observer unmounts. Unmount and remount withingcTime→ instant cache hit. Wait past it → the entry is garbage-collected and the next mount loads from scratch.
The demo lets you unmount/remount and watch the cache hit (or miss, after gcTime).
Errors don't always blank the screen
A first load failure → status: 'error', show the error + a retry. But a background refetch failure keeps the last good data on screen (you just get an error alongside the stale data). The demo has a "next fetch fails" toggle so you can see both paths.
How it's built
A tiny module-level cache + a fake fetcher reproduce the behaviour — status/fetchStatus tracked separately, cache surviving unmount for gcTime, background refetch keeping stale data. The transition log lives in an external store (useSyncExternalStore) so logging never re-renders the query view it's measuring. No data-fetching library; the point is the mechanics.
If this finally separated isLoading from isFetching for you, a star helps others find it: https://github.com/dev48v/react-query-states
Top comments (0)