TanStack Query (React Query)
Server state is fundamentally different from local UI state: it lives on a remote machine, can change without your app knowing, and is shared across components. TanStack Query (formerly React Query) treats this difference seriously, giving you caching, deduplication, background refetching, and stale-data management out of the box. Instead of hand-writing useState plus useEffect plus loading and error flags for every endpoint, you describe what data you need and the library handles when and how to keep it fresh.
Installing and setting up the provider
Install the package and wrap your app in a QueryClientProvider. The QueryClient holds the cache that every query and mutation reads from and writes to.
npm install @tanstack/react-query
// src/main.jsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App.jsx";
const queryClient = new QueryClient();
createRoot(document.getElementById("root")).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>
);
Reading data with useQuery
The core hook is useQuery. You give it a query key (a serializable array that uniquely identifies the data) and a query function that returns a promise. The hook returns the cached data plus status flags so you can render loading and error states declaratively.
import { useQuery } from "@tanstack/react-query";
async function fetchUsers() {
const res = await fetch("https://jsonplaceholder.typicode.com/users");
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
return res.json();
}
export default function UserList() {
const { data, isPending, isError, error } = useQuery({
queryKey: ["users"],
queryFn: fetchUsers,
});
if (isPending) return <p>Loading…</p>;
if (isError) return <p>Error: {error.message}</p>;
return (
<ul>
{data.map((u) => (
<li key={u.id}>{u.name}</li>
))}
</ul>
);
}
Output:
Loading… (first render)
Leanne Graham (after the request resolves)
Ervin Howell
Clementine Bauch
...
If a second component also calls useQuery({ queryKey: ["users"] }) while the request is in flight, TanStack Query deduplicates it — only one network call is made and both components share the result.
Query keys and parameters
Query keys are how the cache is partitioned. When a key changes, TanStack Query treats it as a different query and fetches automatically. Include every variable that affects the result so each unique input gets its own cache entry.
function UserDetail({ userId }) {
const { data, isPending } = useQuery({
queryKey: ["users", userId],
queryFn: () =>
fetch(`https://jsonplaceholder.typicode.com/users/${userId}`).then((r) =>
r.json()
),
enabled: userId != null, // skip the query until we have an id
});
if (isPending) return <p>Loading…</p>;
return <h2>{data.name}</h2>;
}
The enabled option lets you create dependent queries — the fetch waits until its prerequisite data exists.
staleTime, gcTime, and background refetch
This is where the library shines. Every cached entry has two independent lifecycles:
| Option | Default | What it controls |
|---|---|---|
staleTime | 0 | How long data is considered fresh. Fresh data is served from cache with no refetch. |
gcTime | 5 * 60_000 | How long unused (no mounted observers) data stays in the cache before garbage collection. |
When data is stale (older than staleTime), TanStack Query keeps showing it instantly from cache, then quietly refetches in the background and swaps in the fresh result. By default it also refetches on window refocus and network reconnect — so users returning to a tab see up-to-date data automatically.
useQuery({
queryKey: ["users"],
queryFn: fetchUsers,
staleTime: 60_000, // treat data as fresh for 1 minute — no refetch on remount
gcTime: 10 * 60_000, // keep it cached 10 minutes after the last component unmounts
});
Tip: A
staleTimeof0(the default) means data is stale immediately, so revisiting a screen triggers a background refetch every time. BumpingstaleTimeis the single most effective way to cut redundant network traffic.
Invalidating queries
After a write, you usually want dependent queries to refetch. Rather than manually setting state, you invalidate the relevant keys and let the library re-run their query functions. invalidateQueries matches by key prefix, so invalidating ["users"] also refreshes ["users", 1], ["users", 2], and so on.
import { useQueryClient, useMutation } from "@tanstack/react-query";
function AddUserButton() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (newUser) =>
fetch("https://jsonplaceholder.typicode.com/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newUser),
}).then((r) => r.json()),
onSuccess: () => {
// any query whose key starts with ["users"] becomes stale and refetches
queryClient.invalidateQueries({ queryKey: ["users"] });
},
});
return (
<button onClick={() => mutation.mutate({ name: "Ada Lovelace" })}>
{mutation.isPending ? "Adding…" : "Add user"}
</button>
);
}
Why it removes most fetching boilerplate
Compare a hand-rolled effect to the useQuery version. The manual approach needs explicit state for data, loading, and error; cleanup to avoid race conditions; and offers no caching, deduplication, or background updates. TanStack Query collapses all of that into a single declarative call and adds capabilities you would rarely build yourself.
| Concern | useEffect + useState | TanStack Query |
|---|---|---|
| Loading / error state | Manual | Built in (isPending, isError) |
| Caching across components | None | Automatic by query key |
| Request deduplication | None | Automatic |
| Background refetch | Manual | On focus, reconnect, interval |
| Stale-while-revalidate | None | staleTime driven |
| Cache cleanup | None | gcTime garbage collection |
Best Practices
- Choose query keys that include every input affecting the result so cache entries stay distinct and invalidation is precise.
- Set a sensible
staleTimeper query — short for fast-changing data, long for reference data — to avoid needless refetches. - Keep query functions pure and throwing: reject the promise on non-2xx responses so
isErrorreflects reality. - Invalidate by key prefix after mutations instead of manually mutating cached state, unless you intentionally do optimistic updates.
- Use
enabledfor dependent or conditional queries rather than calling hooks conditionally. - Add the React Query Devtools in development to inspect cache state, freshness, and in-flight fetches.
- Treat the
QueryClientas a singleton created once outside the component tree.