tanstack-queryreact-querycachingdata-fetchingjsonapi

TanStack Query: Caching JSON API Responses the Right Way

·9 min read·APIs

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

ts
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:

ts
queryKey: ["users"]                          // the list
queryKey: ["user", userId]                   // one user, keyed by id
queryKey: ["users", { status, page }]        // a filtered, paginated list

Same 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:

ts
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) — 0 is 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.

Frequently asked questions

TanStack Query (formerly React Query) manages server data as a cache: it handles loading and error state, caches JSON responses keyed by a query key, dedupes concurrent requests, and refetches stale data in the background — replacing the manual useState/useEffect fetching boilerplate.

staleTime is how long fetched data is considered fresh and served without refetching; after it, the query refetches in the background. gcTime is how long an unused cache entry is kept before it's garbage-collected. In short, staleTime controls refetching and gcTime controls eviction.

It's an array that uniquely identifies a cache entry and must include every input that affects the result, like ["user", userId]. The same key serves the same cached data; changing any value creates a separate entry that fetches independently.

Call queryClient.invalidateQueries({ queryKey: [...] }) in the mutation's onSuccess. That marks matching queries stale so TanStack Query refetches them, keeping the UI in sync with the server without manually re-fetching.

No. TanStack Query is designed to be the source of truth for server data. Copying it into useState defeats the cache, causes stale copies, and reintroduces the syncing bugs the library exists to prevent. Use React state only for UI state.

Try JSONKit — JSON Developer Tools

Format, validate, convert and diff JSON. Instant, accurate and 100% private.