Server State with TanStack Query
In Data Fetching Patterns you fetched data inside an effect and met the problems that appear as an app grows: the same data is downloaded again on every visit, two components fetch the same thing twice, nothing retries after a network blip, and data goes out of date while the tab sits open. Server state, data that really lives on a server, needs a cache, and TanStack Query (@tanstack/react-query, version 5) is the most widely used cache for React apps.
In this lesson you will learn how a query cache thinks, build a tiny one to see the idea run, and then use TanStack Query for real: queries, keys, freshness settings, pagination, mutations, cache invalidation and optimistic updates.
How a query cache thinks
A query cache stores each response under a query key, an array such as ["products", { page: 2 }]. Every component asking for the same key shares one request and one cached result. Each cached entry is either:
- fresh: recent enough to use without asking the server again, or
- stale: still shown immediately, but refetched in the background when a good moment comes.
This "show the old data now, refresh quietly" strategy is called stale-while-revalidate. It is why apps built with a query cache feel instant when you go back to a page. This runnable example builds a miniature cache with useSyncExternalStore so you can watch the idea work. Each product's data stays fresh for 4 seconds:
import { useEffect, useState, useSyncExternalStore } from "react";
import { createRoot } from "react-dom/client";
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const prices = { Saree: 18500, Kurta: 1299 };
async function fetchProduct(name) {
console.log(`Network: fetching ${name}`);
await wait(800);
return `${name}: ₹${prices[name].toLocaleString("en-IN")}`;
}
// The cache: key -> { data, fetchedAt, fetching }
const cache = new Map();
const listeners = new Set();
const subscribe = (l) => (listeners.add(l), () => listeners.delete(l));
function update(key, changes) {
cache.set(key, { ...cache.get(key), ...changes }); // a new object, so React notices
listeners.forEach((l) => l());
}
function useLiteQuery(key, fetcher, staleTime) {
const entry = useSyncExternalStore(subscribe, () => cache.get(key));
useEffect(() => {
const current = cache.get(key);
const isStale = !current || Date.now() - current.fetchedAt > staleTime;
if (isStale && !current?.fetching) {
update(key, { fetching: true });
fetcher().then((data) => update(key, { data, fetchedAt: Date.now(), fetching: false }));
}
}, [key]);
return { data: entry?.data, isPending: !entry?.data, isFetching: Boolean(entry?.fetching) };
}
function Product({ name }) {
const { data, isPending, isFetching } = useLiteQuery(name, () => fetchProduct(name), 4000);
if (isPending) return <p>Loading {name}...</p>;
return <p>{data} {isFetching && <small>(refreshing...)</small>}</p>;
}
function Shop() {
const [name, setName] = useState("Saree");
return (
<>
{Object.keys(prices).map((n) => <button key={n} onClick={() => setName(n)}>{n}</button>)}
<button onClick={() => (prices.Saree += 500)}>Server raises saree price</button>
<Product name={name} />
</>
);
}
createRoot(document.getElementById("root")).render(<Shop />);Load the example, click "Kurta", then quickly click "Saree" again. The console shows only two network requests:
Network: fetching Saree Network: fetching Kurta
The saree appears instantly the second time, because its entry is still fresh. Now click "Server raises saree price" and "Kurta", wait more than 4 seconds, and click "Saree". The old price appears at once with "(refreshing...)", a new request for the saree runs, and the new price replaces the old one. Notice the two flags: isPending means "no data yet, show a loader", while isFetching means "a request is running", which may happen while old data is on screen. TanStack Query uses the same names.
A real cache must also handle errors, retries, cancelling, removing unused entries and much more. That is what the library is for.
Setting up TanStack Query
npm install @tanstack/react-query
npm install -D @tanstack/react-query-devtoolsCreate one QueryClient for the whole app (outside any component, so it is not recreated on every render) and provide it at the top:
// src/main.jsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 60 * 1000 } }, // fresh for one minute
});
createRoot(document.getElementById("root")).render(
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools />
</QueryClientProvider>
);The DevTools panel lists every query, its key, its state (fresh, stale, fetching, inactive) and its data. It is only included in development builds. These defaults explain most "why did it refetch?" questions:
| Setting | Default | Meaning |
|---|---|---|
staleTime | 0 | Data is stale immediately, so it is refetched on the next trigger |
gcTime | 5 minutes | Unused cache entries are deleted after this time |
retry | 3 | Failed queries retry three times, with growing delays, before showing an error |
refetchOnWindowFocus | true | Stale queries refetch when the user returns to the tab |
refetchOnMount, refetchOnReconnect | true | Stale queries refetch when a new component uses them or the network returns |
A staleTime of a minute or more is a sensible starting point for data that does not change every second, such as a product catalogue.
Queries, keys and pagination
Put every value the request depends on into the key. When the key changes, TanStack Query fetches the new data and caches each key separately. A queryOptions object keeps the key and the function together, so every place that needs this data uses exactly the same key:
import { queryOptions, useQuery, keepPreviousData } from "@tanstack/react-query";
async function getJson(url, signal) {
const res = await fetch(url, { signal });
if (!res.ok) throw new Error(`Server answered ${res.status}`); // a thrown error means "failed"
return res.json();
}
export const productsQuery = (page) =>
queryOptions({
queryKey: ["products", { page }],
queryFn: ({ signal }) => getJson(`/api/products?page=${page}`, signal),
staleTime: 5 * 60 * 1000,
});
function ProductTable() {
const [page, setPage] = useState(1);
const { data, isPending, isError, error, isFetching, isPlaceholderData } = useQuery({
...productsQuery(page),
placeholderData: keepPreviousData, // keep showing page 1 while page 2 loads
});
if (isPending) return <p>Loading products...</p>;
if (isError) return <p role="alert">Could not load products: {error.message}</p>;
return (
<>
<ul style={{ opacity: isPlaceholderData ? 0.5 : 1 }}>
{data.items.map((p) => <li key={p.id}>{p.name}: ₹{p.price}</li>)}
</ul>
<button onClick={() => setPage((p) => p - 1)} disabled={page === 1}>Previous</button>
<button onClick={() => setPage((p) => p + 1)} disabled={isPlaceholderData || !data.hasMore}>Next</button>
{isFetching && <small> Updating...</small>}
</>
);
}The queryFn receives a signal that TanStack Query aborts when the result is no longer needed, and it must throw on failure; returning an error object would be cached as if it were good data. Other useful options: enabled: Boolean(userId) waits until a value exists before fetching, and useSuspenseQuery works with <Suspense> boundaries instead of isPending checks. Because productsQuery(2) is a plain object, you can also warm the cache before the user clicks: queryClient.prefetchQuery(productsQuery(2)), or call queryClient.ensureQueryData(...) from a React Router loader.
Mutations and invalidation
Reading is a query; creating, updating or deleting is a mutation. After a mutation succeeds, the cached lists are out of date, so you invalidate them: TanStack Query marks every query whose key starts with the given key as stale and refetches the ones on screen:
import { useMutation, useQueryClient } from "@tanstack/react-query";
function AddProductForm() {
const queryClient = useQueryClient();
const addProduct = useMutation({
mutationFn: (product) =>
fetch("/api/products", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(product),
}).then((res) => {
if (!res.ok) throw new Error("Could not save the product");
return res.json();
}),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["products"] }), // every page
});
function handleSubmit(formData) {
addProduct.mutate({ name: formData.get("name"), price: Number(formData.get("price")) });
}
return (
<form action={handleSubmit}>
<input name="name" aria-label="Product name" />
<input name="price" type="number" aria-label="Price" />
<button disabled={addProduct.isPending}>{addProduct.isPending ? "Saving..." : "Add"}</button>
{addProduct.isError && <p role="alert">{addProduct.error.message}</p>}
{addProduct.isPending && <p style={{ opacity: 0.5 }}>{addProduct.variables.name} (saving)</p>}
</form>
);
}Two details matter. Returning the promise from onSuccess keeps the mutation pending until the lists have been refetched, so the button does not re-enable before the new product is visible. And addProduct.variables holds what you passed to mutate, which gives you a simple optimistic update: show the new item, faded, while it saves. If the request fails, the faded item disappears and the error shows. For more complex cases, onMutate can write the expected result into the cache directly and roll it back in onError.
Common mistakes
- Copying query data into
useState. It stops updating when the cache refreshes. Usedatadirectly and derive values from it. - Keys that miss a dependency.
["products"]for every page shows page 1's data on page 2. Put every input into the key. - Not throwing on HTTP errors.
fetchresolves on 404 and 500, so checkres.okand throw. - Creating the
QueryClientinside a component. The cache is thrown away on every render. - Forgetting to invalidate after a mutation. The list keeps showing old data until something else refetches it.
- Using it for client state. A theme or an open menu is not server data; keep it in React state or a store, as in Choosing State Management.
Key points
- Server data is a cache: TanStack Query stores it by query key and shares it between components.
- Fresh data is used as is; stale data is shown immediately and refetched in the background.
- Defaults:
staleTime0,gcTime5 minutes, 3 retries, refetch on focus, mount and reconnect. isPendingmeans no data yet;isFetchingmeans a request is running, possibly behind old data.queryOptionskeeps keys and fetch functions together foruseQuery, prefetching and loaders.useMutationchanges data;invalidateQueriesmarks related queries stale so they refetch.
Exercise
Extend the mini cache example: add a "Refresh" button inside Product that forces a new request for the current product even if it is fresh, and show the time of the last fetch under the price (use toLocaleTimeString("en-IN")). Hint: give useLiteQuery a returned refetch function that runs the same fetching code without the freshness check.
Show solution
import { useEffect, useState, useSyncExternalStore } from "react";
import { createRoot } from "react-dom/client";
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const prices = { Saree: 18500, Kurta: 1299 };
async function fetchProduct(name) {
console.log(`Network: fetching ${name}`);
await wait(800);
return `${name}: ₹${prices[name].toLocaleString("en-IN")}`;
}
const cache = new Map();
const listeners = new Set();
const subscribe = (l) => (listeners.add(l), () => listeners.delete(l));
function update(key, changes) {
cache.set(key, { ...cache.get(key), ...changes });
listeners.forEach((l) => l());
}
function useLiteQuery(key, fetcher, staleTime) {
const entry = useSyncExternalStore(subscribe, () => cache.get(key));
function refetch() {
if (cache.get(key)?.fetching) return;
update(key, { fetching: true });
fetcher().then((data) => update(key, { data, fetchedAt: Date.now(), fetching: false }));
}
useEffect(() => {
const current = cache.get(key);
if (!current || Date.now() - current.fetchedAt > staleTime) refetch();
}, [key]);
return { data: entry?.data, fetchedAt: entry?.fetchedAt, isFetching: Boolean(entry?.fetching), refetch };
}
function Product({ name }) {
const { data, fetchedAt, isFetching, refetch } = useLiteQuery(name, () => fetchProduct(name), 4000);
if (!data) return <p>Loading {name}...</p>;
return (
<div>
<p>{data} {isFetching && <small>(refreshing...)</small>}</p>
<small>Last fetched at {new Date(fetchedAt).toLocaleTimeString("en-IN")}</small>{" "}
<button onClick={refetch}>Refresh</button>
</div>
);
}
function Shop() {
const [name, setName] = useState("Saree");
return (
<>
{Object.keys(prices).map((n) => <button key={n} onClick={() => setName(n)}>{n}</button>)}
<Product name={name} />
</>
);
}
createRoot(document.getElementById("root")).render(<Shop />);refetch is the fetching code on its own, and the effect calls it only when the entry is missing or stale. The button calls it directly, which is what TanStack Query's own refetch() does.
Quick Quiz
1. Two components on the same screen call useQuery with the key ["user", 7]. How many requests are sent?
Queries with the same key are de-duplicated and share one cache entry.
2. With the default staleTime of 0, what happens when a user returns to a page whose data is cached?
Stale data is still shown; it is simply refetched behind the scenes.
3. What is the difference between isPending and isFetching?
Show a full loader for isPending and a small "updating" hint for isFetching.
4. After adding a product with useMutation, how do you update the product lists on screen?
Invalidation marks matching queries as stale and refetches the active ones.
Learn React with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
