TanStack Query: Server-State Management for React
Once you’ve learned the basics of React — components, props, useState, useEffect — fetching data from an API feels like the obvious next step. It usually looks something like this:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => setUser(data))
.catch((err) => setError(err))
.finally(() => setLoading(false));
}, [userId]);
if (loading) return <p>Loading…</p>;
if (error) return <p>Something went wrong.</p>;
return <h1>{user.name}</h1>;
}
This works for a single component. But it doesn’t scale, and here’s why.
The Problem: Server State Is Not Component State
useState and useEffect were designed for client state — things like “is this dropdown open” that live and die with a component. Data from an API is server state: it lives on a server, can be stale the moment you receive it, might be needed by multiple components at once, and can change from actions outside your control. Treating it like client state creates real problems:
- No caching — navigate away from
UserProfileand back, and it re-fetches from scratch, even though nothing changed. - Duplicate requests — if two components on the same page both need the same user, you either fetch it twice or thread it through props/context manually.
- No automatic refetching — if the tab was in the background and the data might now be stale, or the user refocuses the window, nothing tells your
useEffectto run again. - Race conditions — if
userIdchanges quickly, an olderfetchcan resolve after a newer one and overwrite fresher data, unless you carefully guard against it. - Boilerplate — every component that fetches data ends up rewriting the same
loading/error/datastate trio.
TanStack Query (formerly React Query) is a library built specifically to manage server state: fetching, caching, synchronizing, and updating it, so you stop reimplementing the same logic in every component.
Setting It Up
npm install @tanstack/react-query
Wrap your app in a QueryClientProvider once, near the root:
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient();
function App() {
return (
<QueryClientProvider client={queryClient}>
<UserProfile userId="42" />
</QueryClientProvider>
);
}
The useQuery Hook
The same UserProfile component, rewritten with useQuery:
import { useQuery } from "@tanstack/react-query";
function fetchUser(userId) {
return fetch(`/api/users/${userId}`).then((res) => res.json());
}
function UserProfile({ userId }) {
const { data, isLoading, isError } = useQuery({
queryKey: ["user", userId],
queryFn: () => fetchUser(userId),
});
if (isLoading) return <p>Loading…</p>;
if (isError) return <p>Something went wrong.</p>;
return <h1>{data.name}</h1>;
}
Two things do all the work here:
queryKey— a unique identifier for this piece of data,["user", userId]. TanStack Query uses it as the cache key. Any other component that callsuseQuerywith the same key gets the same cached data instantly, and only one network request is made even if ten components ask for it at once.queryFn— any function that returns a promise resolving to the data. TanStack Query calls it, tracks its loading/error/success state for you, and stores the result underqueryKey.
Everything the manual version tracked by hand — loading, error, data — comes back as isLoading, isError, data (plus extras like isFetching for background refetches) without you writing a single useState.
What You Get for Free
- Caching — switch away from
UserProfileand back, and cached data renders immediately while a fresh fetch happens quietly in the background. - Automatic refetching — by default, TanStack Query refetches on window refocus and network reconnect, so displayed data doesn’t go stale unnoticed.
- Deduplication — multiple components querying the same
queryKeyat the same time share a single network request. - Retries — failed requests are retried automatically with backoff, configurable per query.
- Mutations — a matching
useMutationhook handles POST/PUT/DELETE calls and can invalidate related queries afterward:
import { useMutation, useQueryClient } from "@tanstack/react-query";
function useUpdateUser(userId) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (newData) =>
fetch(`/api/users/${userId}`, {
method: "PUT",
body: JSON.stringify(newData),
}),
onSuccess: () => {
// Refetch the user query so the UI reflects the update.
queryClient.invalidateQueries({ queryKey: ["user", userId] });
},
});
}
When to Reach for TanStack Query
- Any time a component fetches data from a REST or GraphQL API and you find yourself writing
loading/error/datastate by hand. - When the same data is needed in multiple places and you don’t want to prop-drill or duplicate fetches.
- Client-side data fetching in a traditional React SPA, or inside Client Components in a Next.js App Router project for data that changes after the initial server render (things like live notifications, polling, or user-triggered refetches) — for the initial page load, Server Components fetching data directly is often simpler, and the two approaches combine well: server-render the first paint, then let TanStack Query take over for subsequent client-side updates.
If your data-fetching useEffects are starting to feel repetitive or fragile, that’s usually the sign it’s time to bring in a dedicated server-state library rather than reaching for more useState.