Almost every React application works with server data: product listings, user profiles, order history. The task seems simple — fire a request, store the data in a variable, render it. In practice, though, it's much harder: you need to show a loading indicator, handle errors, avoid redundant requests, and refresh the list after a change. You could write all of that yourself — and it would be a lot of code full of bugs. Or you could use TanStack Query.
Why fetch inside useEffect doesn't work well
The most obvious way to load data in React is to make a request inside useEffect and store the result in useState:
function ProductList() {
const [data, setData] = useState(null);
useEffect(() => {
fetch("/api/products")
.then((r) => r.json())
.then(setData);
}, []);
return <ul>{data?.map((p) => <li key={p.id}>{p.name}</li>)}</ul>;
}
This works for the simplest case, but problems appear quickly:
- No loading indicator. While data is in flight, the component renders nothing.
- No error handling. If the request fails, the user sees nothing.
- No cache. Every time the component mounts, a new request goes out — even if the data is already there.
- Race conditions. If the user switches filters quickly, multiple requests fire in parallel and arrive in an arbitrary order — leaving stale data on screen.
- Duplicate requests. If two components need the same list, each makes its own request independently.
TanStack Query (formerly React Query) solves all of these problems out of the box.
What TanStack Query is
TanStack Query is a library for managing server state in React. The core idea: data from the server is not part of your application state — it's a cache. The library manages that cache: fetching data, storing it, refreshing it in the background, and invalidating it when needed.
Installation:
npm install @tanstack/react-query
Wrap the application in a provider once:
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient();
function App() {
return (
<QueryClientProvider client={queryClient}>
<Router />
</QueryClientProvider>
);
}
How to make a request with useQuery
Any data request is declared with useQuery. It has two required parameters:
queryKey— a unique key under which Query stores the data in the cache. If two components use the same key, they share the same data without a second request.queryFn— an async function that fetches the data. It must return the data or throw an error.
import { useQuery } from "@tanstack/react-query";
function useProducts() {
return useQuery({
queryKey: ["products"],
queryFn: async (): Promise<Product[]> => {
const res = await fetch("/api/products");
if (!res.ok) throw new Error("Failed to load products");
return res.json();
},
});
}
A good practice is to extract useQuery into a dedicated hook (useProducts, useUser, etc.) rather than writing it directly in the component. That makes the loading logic easy to reuse.
Inside the component, use what the hook returns:
function ProductList() {
const { data, isPending, isError } = useProducts();
if (isPending) return <Spinner />;
if (isError) return <p>Something went wrong. Try refreshing the page.</p>;
return (
<ul>
{data.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
);
}
isPending — the request is still in flight, no data yet. isError — the request failed. data — the loaded data (only available when both isPending and isError are false).
Parameters in the query key
If data depends on a parameter (category, search term, page number) — include that parameter in queryKey. Query automatically fires a new request whenever the key changes:
function useProducts(category: string) {
return useQuery({
queryKey: ["products", category],
queryFn: async (): Promise<Product[]> => {
const res = await fetch(`/api/products?category=${category}`);
if (!res.ok) throw new Error("Failed to load");
return res.json();
},
});
}
When the category changes, Query fetches new data while the old data stays in the cache — if the user switches back to the previous category, it appears instantly while a background refresh runs.
Mutations: changing data
useQuery is for reading. For creating, updating, and deleting, use useMutation.
import { useMutation, useQueryClient } from "@tanstack/react-query";
function useCreateProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreateProductInput) =>
fetch("/api/products", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
}).then((r) => {
if (!r.ok) throw new Error("Failed to create product");
return r.json();
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["products"] });
},
});
}
After a successful creation, invalidateQueries is called — Query marks the product list cache as stale and automatically refetches it. The list on screen updates on its own, without any manual state update.
In the component, the mutation is triggered via mutate:
function CreateProductButton() {
const { mutate, isPending } = useCreateProduct();
return (
<button
onClick={() => mutate({ name: "New product", price: 100 })}
disabled={isPending}
>
{isPending ? "Saving..." : "Create product"}
</button>
);
}
Background refresh
By default, TanStack Query refetches data in several situations:
- when the user returns to the browser tab;
- when the component mounts again;
- when the internet connection is restored.
The user sees the old data immediately while the updated data loads in the background. This is called stale-while-revalidate — show what you have and quietly update.
How long data is considered fresh is controlled via staleTime:
useQuery({
queryKey: ["products"],
queryFn: fetchProducts,
staleTime: 5 * 60 * 1000, // data is fresh for 5 minutes, no background refetch
});
In short
useEffect + useStatefor data fetching is a source of an entire class of bugs: no cache, no error handling, race conditions.- TanStack Query manages server data as a cache: fetches it, stores it, and refreshes it in the background.
useQuerydeclares a request;queryKeyis the cache key,queryFnis the fetch function.- Parameters in
queryKeyautomatically switch the request when filters change. isPending/isError/data— request states available directly from the hook.useMutation— for changing data;invalidateQueriesafter success refreshes the related cache.- Background refresh when returning to a tab or reconnecting to the network — out of the box.
What to read next
- State in React — the difference between local and server state.
- Forms in React — how data from requests flows into edit forms.
- Error handling and Suspense — how to handle errors and loading states systematically.