Topic 14.6
Server State with TanStack Query
In one line
TanStack Query keeps server data in a shared cache identified by query keys: useQuery reads it (with isPending, isFetching and error), staleTime decides when data needs refreshing and gcTime when unused data is deleted, and useMutation plus invalidateQueries change data on the server and refresh every screen that shows it.
Think of it like this
A school notice board. Instead of every student phoning the office to ask "when is the trip?", the office pins the answer on the board. Everyone reads the board. Each notice has a title (the key) and a date: if it's recent, you trust it; if it's old, someone checks with the office and pins a fresh copy, while you keep reading the old one. When plans change, the office marks the notice "out of date" and replaces it. TanStack Query is that notice board for your app's server data.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Server state
- Your app's copy of data that a server owns; it can change elsewhere and go out of date.
- QueryClient
- The object that holds TanStack Query's cache and settings. One per app.
- Query key
- An array that names a piece of server data, like
["user", 7]. Same key, same cache entry. - Query function
- Your function that returns a promise of the data, such as a
fetchcall. - Stale
- Data that may be out of date. It's still shown, but it gets refetched in the background.
- staleTime
- How long fetched data counts as fresh (no refetch). Default 0.
- gcTime
- How long unused data stays in the cache before it's deleted. Default 5 minutes.
- Background refetch
- Fetching new data while still showing the cached data.
- Mutation
- A request that changes data on the server, run with
useMutation. - Invalidate
- Mark cached queries as stale so they refetch, usually after a mutation.
Step by step
01Install and provide the client
One client for the whole app, created outside components (creating it during render would make a new empty cache on every render).
// npm install @tanstack/react-query
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient();
createRoot(document.getElementById("root")).render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>,
);02Read data with useQuery
Name the data with a key that includes every input, give a function that returns a promise, and render by status.
function User({ id }) {
const { data, isPending, isError, error, isFetching } = useQuery({
queryKey: ["user", id],
queryFn: () => fetch(`/api/users/${id}`).then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`); // fetch doesn't reject on 404/500
return r.json();
}),
});
if (isPending) return <Skeleton />;
if (isError) return <p role="alert">{error.message}</p>;
return <h2>{data.name} {isFetching && <small>updating</small>}</h2>;
}03Understand the life of a cache entry
Fresh for staleTime, then stale (shown, refetched on triggers). When the last component using it unmounts, it becomes inactive, and after gcTime it's deleted.
04Tune staleTime per query
The first live example mounts two users of the same data with different staleTime, hides them, and shows them again. The stale one refetches in the background; the fresh one doesn't.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000, // trust data for 1 minute
gcTime: 5 * 60_000, // forget unused data after 5 minutes (the default)
},
},
});05Change data with a mutation, then invalidate
The mutation calls the server. On success, invalidate the affected keys; every component using them refetches and updates. The second live example shows the order of requests.
06Add the DevTools while developing
The panel shows each key, its state and its data. It's excluded from production builds automatically.
// npm install -D @tanstack/react-query-devtools
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>Try it yourself
- 1
Make both users fresh
In the first example, change the stale user's
staleTimeto60_000. Predict how manyserver:lines you'll see now. Then set both to0and removelabelfrom the query key: how many requests on the first load? (Same key, so they share one.) - 2
Update the cache without a refetch
In the mutation example, replace the
invalidateQueriescall withqueryClient.setQueryData(["todos"], (old) => [...old, title])using the mutation'sonSuccess: (title) => ...argument. Predict whether a secondGET /todosappears. - 3
Show isFetching during the retry
In the stats example, change
retryto3andretryDelayto100. Predict how many attempts happen before success, and whether the error ever shows.
Code & diagrams
Shown again, both have data instantly from the cache, with no loading state. The stale one refetches in the background (call 3) and updates; the fresh one makes no request.
Preview
Console
Nothing logged yet.Expected console output
server: fetch user 1 (call 1)
server: fetch user 1 (call 2)
first render: stale at once: Loading... | fresh 1 min: Loading...
loaded: stale at once: Asha from call 1 | fresh 1 min: Asha from call 2
server: fetch user 1 (call 3)
shown again: stale at once: Asha from call 1 (refreshing) | fresh 1 min: Asha from call 2
after refetch: stale at once: Asha from call 3 | fresh 1 min: Asha from call 2Two components, one GET: requests for the same key are deduplicated. After the successful POST, invalidation triggers one new GET and both components update. The failed mutation leaves the cache alone.
Preview
Console
Nothing logged yet.Expected console output
server: GET /todos
loaded: 1 todos | Learn Suspense
server: POST /todos ""
error: Title is required
server: POST /todos "Learn TanStack Query"
while saving: Adding...
server: GET /todos
after invalidate: 2 todos | Learn Suspense, Learn TanStack QueryThe first failure is retried automatically after 200 ms (`failures: 1` while still pending). After the retry fails, status becomes `error`. Try again refetches; with no data in the cache, the query goes back to `pending` and then `success`.
Preview
Console
Nothing logged yet.Expected console output
render: pending / fetching / failures: 0
server: GET /stats, attempt 1
render: pending / fetching / failures: 1
server: GET /stats, attempt 2
render: error / idle / failures: 2
server: GET /stats, attempt 3
render: pending / fetching / failures: 0
render: success / idle / failures: 0// Keys from general to specific: easy to invalidate a whole group.
const todoKeys = {
all: ["todos"],
list: (filters) => ["todos", "list", filters],
detail: (id) => ["todos", "detail", id],
};
useQuery({ queryKey: todoKeys.list({ status: "done" }), queryFn: () => getTodos("done") });
useQuery({ queryKey: todoKeys.detail(7), queryFn: () => getTodo(7) });
// After editing todo 7:
queryClient.setQueryData(todoKeys.detail(7), updatedTodo); // write the server's answer directly
queryClient.invalidateQueries({ queryKey: ["todos", "list"] }); // every list, any filters
queryClient.invalidateQueries({ queryKey: todoKeys.all }); // everything about todos
queryClient.invalidateQueries({ queryKey: ["todos"], exact: true }); // only the key ["todos"] itselfBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Leave an input out of the query key
Write useQuery({ queryKey: ["user"], queryFn: () => fetchUser(id) }) and switch id from 1 to 2.
Break #2
Create the QueryClient inside a component
Move const queryClient = new QueryClient(); inside App and make App re-render (for example, from a state change).
Myth vs fact
Myth
TanStack Query is a fetching library like axios.
Fact
It doesn't fetch anything: you give it any function returning a promise. It manages caching, deduplication, refetching, retries and state around that function.
Myth
staleTime and gcTime are the same thing.
Fact
staleTime decides when cached data needs a background refetch. gcTime decides when unused data is deleted. Data can be stale yet still cached for minutes.
Myth
A stale query shows a loading spinner again.
Fact
Stale data is still shown immediately; the refetch happens in the background (isFetching), and isPending stays false.
Myth
With TanStack Query you no longer need any client state tool.
Fact
It replaces the server-data part of global state. UI state (open panels, form drafts, theme) still lives in useState, context or a small store (Topic 15.6).
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
fetchonly rejects on network failure, not on HTTP errors, so a query function must checkres.okand throw; otherwise a 500 page becomesdata. Query also passes anAbortSignalin the function's context (queryFn: ({ signal }) => fetch(url, { signal })), and it cancels the request if the query is no longer needed. - ▸
Query uses structural sharing: after a refetch, unchanged parts of the new data keep the same object identity as before, so
memocomponents and effect dependencies don't see changes that aren't real. Components also only re-render for the result fields they actually read (tracked properties). - ▸
Window-focus refetching surprises people in development (switching to DevTools and back triggers refetches). It's a feature for real users; tune it per query with
refetchOnWindowFocusor by raisingstaleTimerather than turning it off globally. - ▸
For server rendering and render-as-you-fetch, prefetch on the server or in a route loader with
queryClient.prefetchQuery()/ensureQueryData(), then pass the cache to the client withdehydrateandHydrationBoundary;useQuerythen starts with data.
Remember this
- 1
Server state is different. Client state (is the menu open, what's typed in a field) belongs to your app alone. Server state is a copy of data owned by a server: it can change without you knowing, several screens need the same piece, and it goes stale (out of date). Keeping it in
useState+ effects means writing caching, deduplication, refreshing and race handling yourself (Topics 14.2 and 14.7). TanStack Query (formerly React Query, version 5 today,@tanstack/react-query) is a library that does all of it. It isn't a fetching tool: you bring any function that returns a promise. - 2
**Setup and
useQuery.** Create oneQueryClient(it holds the cache) and wrap the app in<QueryClientProvider client={queryClient}>. In a component:const { data, error, isPending, isFetching } = useQuery({ queryKey: ["user", id], queryFn: () => fetchUser(id) }). The query key is an array that names the data: the same key anywhere in the app means the same cache entry. Put every input the function uses in the key (["todos", { status: "done", page: 2 }]): when a key value changes, it's a different entry and Query fetches it. Keys are compared by value (object property order doesn't matter), and two components asking for the same key at once share one request (deduplication). - 3
The states.
statusis about data:"pending"(no data yet),"error"(the last fetch failed; data from an earlier success may still be there),"success"(data is there).fetchStatusis about the request:"fetching","paused"(offline) or"idle". They combine:success+fetchingis a background refetch, where you keep showing data with a small hint. Shortcuts:isPending,isError,isSuccess,isFetching, andisLoading(=isPending && isFetching, the very first load). UseisPendingfor the full loading UI andisFetchingfor a subtle "refreshing" indicator. Failed queries retry 3 times by default with exponential backoff (retry,retryDelay). - 4
**
staleTimevsgcTime. These two settings confuse everyone, so learn them precisely.staleTime(default 0) is how long data counts as fresh. Fresh data is served from the cache with no request. Stale data is still shown instantly, but Query refetches it in the background on certain triggers: a new component mounts using it, the window regains focus, the network reconnects** (refetchOnMount,refetchOnWindowFocus,refetchOnReconnect, alltrueby default), or you callrefetch(). **gcTime("garbage collection time", default 5 minutes**, calledcacheTimein v4) is how long an unused entry (no component is using it) stays in the cache before it's deleted. So:staleTimedecides when to refetch,gcTimedecides when to forget. RaisingstaleTime(say to 60 s for data that rarely changes) is the most common tuning. - 5
Mutations and invalidation. Reading is a query; changing server data (create, update, delete) is a mutation:
const add = useMutation({ mutationFn: (title) => api.addTodo(title), onSuccess: () => queryClient.invalidateQueries({ queryKey: ["todos"] }) }), thenadd.mutate("Buy milk"). The result hasisPending,isError,error,dataandreset. Invalidating marks matching queries as stale and refetches the ones on screen, so every component that shows todos updates. Matching is by prefix:["todos"]matches["todos", 1]and["todos", { page: 2 }]. Alternatives: write the server's answer straight into the cache withqueryClient.setQueryData(key, updater), or update optimistically inonMutateand roll back inonError. - 6
DevTools and more.
@tanstack/react-query-devtoolsadds a floating panel (development only) that lists every query key with its status (fresh, stale, fetching, inactive), its data and buttons to refetch or invalidate. Other features you'll meet:enabled: falseto wait for an input (dependent queries),placeholderData: keepPreviousDatafor pagination without flashing,useInfiniteQueryfor "load more" lists (Topic 24.2),useSuspenseQueryfor Suspense (Topic 14.3),prefetchQueryto load before navigation, andthrowOnErrorto send errors to an error boundary (Topic 14.5). Redux users have the same ideas in RTK Query (Topic 15.4).
Explain it without notes
What is the difference between server state and client state, and why does server state need a special tool?
Explain staleTime and gcTime and what happens when a component mounts with cached data in each case.
What is a query key and what rules should it follow?
Walk through what happens when a mutation succeeds and you call invalidateQueries.
What's the difference between isPending, isLoading and isFetching?
Practice
Write a useProduct(id) hook with TanStack Query that caches each product for 5 minutes as fresh and doesn't run when id is null.
Write a DeleteTodo button using useMutation that removes the todo from the ["todos"] cache with setQueryData on success.
A search page queries ["search", term]. Each keystroke shows a loading state. Make it keep the previous results while the next ones load.
Trade-offs
- ↔
TanStack Query removes a lot of hand-written loading, caching and refetch code, at the cost of a dependency and a cache model (keys, stale vs gc, invalidation) the whole team must understand.
- ↔
Low
staleTimekeeps data current but makes more requests (on every mount and focus); highstaleTimesaves requests but can show outdated data. Choose per query based on how often the data really changes. - ↔
Invalidating refetches from the server (always correct, one more request);
setQueryDataupdates instantly with no request but you must reproduce the server's logic (sorting, computed fields) correctly.
Done when you can
I can set up a QueryClient and read data with
useQuery.I put every input of the query function into the query key.
I can explain
staleTimevsgcTimeand background refetching.I can tell
statusfromfetchStatusand pickisPendingvsisFetching.I can write a mutation that invalidates or updates the cache.
I can add the Query DevTools in development.