Server state with TanStack Query
Queries, caching, background refetching, mutations, invalidation and optimistic updates.
Fetching data with useEffect works, but a real app needs much more: caching so revisiting a page is instant, de-duplicating requests, refetching stale data when the user returns to the tab, retrying failures, pagination, and updating the UI after changes. TanStack Query (formerly React Query) handles all of that. You describe what data you need; it manages when and how it's fetched.
Setup#
Create one QueryClient and provide it at the root:
Your first query#
Compare this with the effect version: no useState, no cleanup, no race conditions. And if two components call useQuery({ queryKey: ["posts"] }), only one request is made — both read the same cache entry.
The status flags
isPending— no data yet (first load).isError/error— thequeryFnthrew (after 3 automatic retries by default).isSuccess/data— data is available.isFetching— a request is in flight, including background refetches while old data is still shown.
Query keys and parameters#
The queryKey is an array that identifies the data. Put every variable the query depends on in it:
When postId changes, the key changes and TanStack Query fetches the new post — or returns it instantly from cache if you've seen it before.
Tip: wrap queries in custom hooks (
usePost(id)) so keys and fetchers live in one place.
Caching: staleTime and gcTime#
Two settings control the cache:
staleTime(default0): how long data counts as fresh. Fresh data is served from cache without refetching. Stale data is still shown immediately, but refetched in the background when a component mounts, the window regains focus, or the network reconnects.gcTime(default 5 minutes): how long unused data stays in memory before it's garbage-collected.
Pagination without flicker#
For "load more" and infinite scrolling, use useInfiniteQuery.
Mutations: changing data#
Use useMutation for creates, updates and deletes. After success, invalidate related queries so they refetch:
invalidateQueries matches by prefix: ["posts"] invalidates ["posts"], ["posts", 5] and ["posts", "page", 2].
Optimistic updates#
For snappy UIs, update the cache before the server responds and roll back if it fails:
Prefetching#
Start fetching before the user clicks, e.g. on hover:
Suspense mode#
useSuspenseQuery suspends instead of returning isPending, so loading and errors are handled by <Suspense> and error boundaries, and data is always defined:
DevTools#
A floating panel shows every query, its key, status and cached data — invaluable for debugging. It's excluded from production builds automatically.
Common mistakes#
- Leaving a variable out of the
queryKey, so different ids share one cache entry. - Forgetting to throw on
!res.ok, so error responses are cached as data. - Creating the
QueryClientinside a component — it's recreated on every render and the cache is lost. - Copying query data into
useState(it goes stale). Usedatadirectly, or derive from it. - Confusing
isPending(no data yet) withisFetching(any request in flight). - Using TanStack Query for purely client state like a modal's open flag.
What's next#
Your apps are getting serious. Next: TypeScript with React, to catch bugs before they reach users.
Check your understanding
Quick quiz
1.What is the
queryKeyinuseQuery({ queryKey: ['posts', page], queryFn })used for?2.After a mutation creates a new post, how do you make the posts list refresh?
3.In TanStack Query v5, which flag means 'there is no data yet' (the first load)?
Finished reading?
Mark this lesson complete to track your progress.