The boilerplate it kills
Every app that consumes a JSON API reinvents the same machinery: a loading flag, an error flag, the fetched data in state, a useEffect to trigger the request, and — usually skipped — caching, deduplication, and refetching. TanStack Query (formerly React Query) replaces all of it with a managed cache keyed by your request. You describe *what* to fetch; it handles loading/error state, caches the JSON, dedupes concurrent requests, and keeps data fresh in the background.
The mental shift: TanStack Query treats server data as a cache of remote state, not as local state you own. Understanding two concepts — query keys and staleness — is most of the battle.
The basic query
const { data, isLoading, error } = useQuery({
queryKey: ["user", userId],
queryFn: () => fetch(`/api/users/${userId}`).then((r) => r.json()),
});That's the whole pattern. queryFn returns the parsed JSON; the hook gives you isLoading, error, and data without a single useState or useEffect. Call the same query from three components and only one network request fires — results are deduped and shared from the cache.
Query keys: the cache's identity
The queryKey is how TanStack Query identifies a cache entry. It's an array, and it must include every input that changes the result:
queryKey: ["users"] // the list
queryKey: ["user", userId] // one user, keyed by id
queryKey: ["users", { status, page }] // a filtered, paginated listSame key → same cache entry (served instantly if fresh). Change any value in the key → a different entry that fetches on its own. Getting keys right is what makes caching, refetching, and invalidation all work — think of the key as the primary key of your cache.
staleTime vs gcTime: the two timers people confuse
These control freshness and eviction, and mixing them up is the most common TanStack Query mistake:
- `staleTime` — how long data is considered fresh. While fresh, the cached JSON is served with no refetch. After it goes stale, the data is still shown, but the query refetches in the background on the next trigger (remount, refocus). Default is 0 — every query is immediately stale, so it refetches eagerly. Raise it (
staleTime: 60_000) for data that doesn't change every second. - `gcTime` (garbage-collection time, formerly
cacheTime) — how long an unused cache entry is kept before eviction. Default 5 minutes. It only matters once *no component* is using the query.
The one-liner: `staleTime` decides when to refetch; `gcTime` decides when to forget. Tuning staleTime up is usually the single biggest win — it stops needless refetches of data that's fine as-is.
Background refetching keeps JSON fresh for free
Once data is stale, TanStack Query refetches automatically on window refocus, network reconnect, and component remount — showing the cached JSON instantly while it revalidates behind the scenes (a stale-while-revalidate model, the same idea as ETag conditional requests). The user never stares at a spinner for data you already have; the update just arrives.
Invalidation: refetch after a mutation
When you change server data (a POST/PUT), you tell the cache which entries are now wrong, and TanStack Query refetches them:
const qc = useQueryClient();
const mutation = useMutation({
mutationFn: (u) => fetch("/api/users", { method: "POST", body: JSON.stringify(u) }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }), // list is stale → refetch
});invalidateQueries marks matching keys stale so the UI reflects the new state without a manual re-fetch. This key-based invalidation is exactly why disciplined query keys pay off.
Practical defaults
- Set a sensible `staleTime` per query (or globally) —
0is rarely what you want for data that isn't real-time. - Structure query keys hierarchically (
["users", filters]) so you can invalidate broadly or narrowly. - Let it manage server state; use React state for UI state — don't copy fetched JSON into
useState. - Pair with a typed client — validate responses with Zod or use tRPC, which integrates with TanStack Query directly.
For the API-side concerns it complements, see JSON pagination patterns and ETag conditional requests.