TanStack Query Guide for React: Server State Made Simple
On this page
Most React apps don't have a "state management" problem. They have a server state problem. The data lives on a backend, you need a copy of it in the browser, and that copy goes stale the moment you fetch it. TanStack Query (formerly React Query) exists to solve exactly this, and once you internalize the mental model, a huge category of boilerplate simply disappears.
This guide walks through the practical patterns you'll actually use day to day.
Why Server State Is Different
Client state — a modal's open/closed flag, the current tab, form input — is owned entirely by your app. You set it, you read it, it's always correct.
Server state is the opposite:
- It's persisted remotely and you only hold a snapshot.
- It can become stale without your app doing anything.
- It needs caching, deduplication, and background refreshing.
- Multiple components may need the same data without re-fetching it.
Trying to manage this with useState + useEffect leads to a familiar mess: loading flags everywhere, race conditions, duplicate requests, and manual cache logic. TanStack Query treats server state as a first-class concept with its own lifecycle.
Getting Started
Install the package and wrap your app in a provider.
npm install @tanstack/react-query
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient();
export function App() {
return (
<QueryClientProvider client={queryClient}>
<Dashboard />
</QueryClientProvider>
);
}
The QueryClient is the brain — it holds the cache and the configuration. One instance per app.
Your First Query
A query is any asynchronous read of data, identified by a query key.
import { useQuery } from "@tanstack/react-query";
function Todos() {
const { data, isPending, isError, error } = useQuery({
queryKey: ["todos"],
queryFn: async () => {
const res = await fetch("/api/todos");
if (!res.ok) throw new Error("Failed to load todos");
return res.json();
},
});
if (isPending) return <p>Loading…</p>;
if (isError) return <p>Error: {error.message}</p>;
return (
<ul>
{data.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
);
}
That's it. No useEffect, no manual loading state, no cleanup. If two components both call useQuery({ queryKey: ["todos"] }), only one network request fires and both share the result.
Understanding Query Keys
The query key is how TanStack Query identifies and caches data. Think of it like a dependency array for your cache.
useQuery({ queryKey: ["todos"], queryFn: getTodos });
useQuery({ queryKey: ["todo", todoId], queryFn: () => getTodo(todoId) });
useQuery({ queryKey: ["todos", { status: "done" }], queryFn: getDoneTodos });
When any value in the key changes, the query re-fetches automatically. This makes pagination and filtering trivial — just put the variables in the key:
const { data } = useQuery({
queryKey: ["todos", { page }],
queryFn: () => getTodos(page),
});
Change page, and the right data loads. The previous page stays cached, so going back is instant.
staleTime vs gcTime
These two options confuse newcomers more than anything else, so let's be precise.
staleTime— how long data is considered fresh. While fresh, TanStack Query serves it from cache and will not refetch. Default is0(immediately stale).gcTime— how long unused data stays in the cache before being garbage collected. Default is 5 minutes.
A common setup for data that doesn't change every second:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60, // 1 minute fresh
gcTime: 1000 * 60 * 5, // 5 minutes in cache
},
},
});
Rule of thumb: raise staleTime to cut down on background refetches for data that's relatively stable (user profiles, config, reference lists). Leave it low for fast-moving data (live feeds, prices).
Mutations: Changing Server Data
Reads use useQuery; writes use useMutation.
import { useMutation, useQueryClient } from "@tanstack/react-query";
function AddTodo() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (title: string) =>
fetch("/api/todos", {
method: "POST",
body: JSON.stringify({ title }),
}).then((r) => r.json()),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});
return (
<button onClick={() => mutation.mutate("New task")}>
{mutation.isPending ? "Adding…" : "Add Todo"}
</button>
);
}
The key line is invalidateQueries. After a successful write, you tell the cache "the todos list is now stale" and TanStack Query refetches it in the background. Your UI updates without any manual state juggling.
Optimistic Updates
For snappy UX, update the cache before the server confirms, then roll back on failure.
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ["todos"] });
const previous = queryClient.getQueryData(["todos"]);
queryClient.setQueryData(["todos"], (old) =>
old.map((t) => (t.id === newTodo.id ? newTodo : t))
);
return { previous };
},
onError: (_err, _newTodo, context) => {
queryClient.setQueryData(["todos"], context.previous);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});
The pattern: snapshot the old data in onMutate, apply the optimistic change, restore the snapshot in onError, and reconcile with the server in onSettled. Reserve this for high-frequency interactions like toggles and likes — it's more code, so don't reach for it everywhere.
Practical Patterns That Scale
Centralize query keys. Scattering string arrays across files leads to typos and inconsistent invalidation. Use a key factory:
export const todoKeys = {
all: ["todos"] as const,
list: (filters) => [...todoKeys.all, "list", filters] as const,
detail: (id) => [...todoKeys.all, "detail", id] as const,
};
Now invalidateQueries({ queryKey: todoKeys.all }) invalidates everything todo-related at once.
Wrap queries in custom hooks. Keep queryFn and key logic out of components:
export function useTodos(filters) {
return useQuery({
queryKey: todoKeys.list(filters),
queryFn: () => fetchTodos(filters),
});
}
Components just call useTodos(filters) and stay clean.
Use enabled for dependent queries. Don't fire a query until its inputs exist:
const { data: user } = useQuery({ queryKey: ["user"], queryFn: getUser });
const { data: projects } = useQuery({
queryKey: ["projects", user?.id],
queryFn: () => getProjects(user.id),
enabled: !!user?.id,
});
Prefetch on intent. Warm the cache when a user hovers a link so the next page feels instant:
queryClient.prefetchQuery({
queryKey: todoKeys.detail(id),
queryFn: () => fetchTodo(id),
});
Common Mistakes to Avoid
- Putting server data in
useState. If it came from an API, let TanStack Query own it. Copying it into local state desyncs you from the cache. - Over-fetching with
staleTime: 0everywhere. Tune it per data type instead of accepting constant background refetches. - Forgetting to throw on HTTP errors.
fetchdoesn't reject on 4xx/5xx. If you don't throw,isErrornever fires. - Unstable query keys. Inline objects created during render are fine (TanStack Query hashes them structurally), but make sure the values are stable, not the references.
Don't Forget the Devtools
The official devtools are the fastest way to understand what's happening in your cache.
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
<ReactQueryDevtools initialIsOpen={false} />;
You'll see every query, its status, its data, and when it refetches. It turns the cache from a black box into something observable.
FAQ
Is TanStack Query a replacement for Redux?
Not exactly. It replaces Redux for server state. Many apps that adopt it find they no longer need Redux at all, since most of their global state was actually cached API data. For genuine client state (themes, wizards, UI toggles), use useState, context, or a lightweight store like Zustand.
Does it only work with fetch?
No. The queryFn just needs to return a promise. Use axios, ky, GraphQL clients, or anything async. TanStack Query doesn't care how you fetch.
Can I use it with Next.js or server components? Yes. It supports SSR and hydration, and integrates with the Next.js App Router. You can prefetch on the server and hydrate the cache on the client so users see data immediately.
What's the difference between isPending and isFetching?
isPending means there's no cached data yet (the true first load). isFetching is true any time a request is in flight, including background refetches when you already have data to show. Use isPending for full-page spinners and isFetching for subtle "refreshing" indicators.
How do I handle pagination?
Put the page number in the query key and optionally use placeholderData: keepPreviousData so the old page stays visible while the new one loads. For infinite scroll, use the dedicated useInfiniteQuery hook.
Is it heavy? The core library is small and tree-shakeable, and it almost always removes more code than it adds by eliminating manual loading/error/caching logic across your app.
Wrapping Up
TanStack Query's core insight is that server state deserves its own tool. Once you stop treating remote data like local state, the patterns become obvious: queries for reads, mutations for writes, query keys for identity, and invalidation to keep things fresh. Start simple with useQuery, layer in mutations and invalidation, tune staleTime, and reach for optimistic updates only where the UX demands it. The result is less code, fewer bugs, and an app that feels fast.
Sources
Related Articles
AI Code Review: The Complete Guide for Engineering Teams (2026)
A definitive, practical guide to AI code review in 2026 — how it works, where it helps and where it doesn't, how to roll it out, prompt and config patterns, security trade-offs, and the metrics that prove it's working.