What you'll learn
Quick Answer
TanStack Query (formerly React Query) is a data-fetching library for React. Instead of trackingloading,error, anddataby hand inuseEffect, you calluseQuery({ queryKey, queryFn })and get all three back, plus caching, request deduplication, and automatic background refetching. You still write the fetch call itself - Query manages everything around it.
What the useEffect pattern misses
Here is the data-fetching code almost every React tutorial starts with:
function useUser(id) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let ignore = false;
setLoading(true);
fetch(`/api/users/${id}`)
.then((r) => r.json())
.then((json) => { if (!ignore) { setData(json); setLoading(false); } })
.catch((e) => { if (!ignore) { setError(e); setLoading(false); } });
return () => { ignore = true; };
}, [id]);
return { data, loading, error };
}It works, but it has no caching (every mount refetches from scratch), no deduplication (two components asking for the same user make two requests), no background refresh, and no shared state (each component keeps its own copy). The ignore flag is there to dodge a race condition and a "set state on an unmounted component" warning - and you have to remember it every single time. TanStack Query solves all of this once, for every query.
Setup: QueryClient and provider
Install the package and wrap your app once:
npm install @tanstack/react-queryimport { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient();
function App() {
return (
<QueryClientProvider client={queryClient}>
<Dashboard />
</QueryClientProvider>
);
}The QueryClient holds the cache, and every component rendered inside the provider can call Query's hooks. Create exactly one client for the whole app - putting it inside a component would throw the cache away on every render, so define it at module scope or with useState(() => new QueryClient()). You can set defaults on it, such as new QueryClient({ defaultOptions: { queries: { staleTime: 60000 } } }), and change them per query later.
While you are learning, add the @tanstack/react-query-devtools panel next to the provider. It shows every cached query, its status, and its current data, so the cache stops being invisible - you can watch entries go stale and refetch as you click around. It is stripped from production builds automatically.
useQuery: one hook, every state
useQuery takes a query key and a query function:
import { useQuery } from "@tanstack/react-query";
function UserCard({ id }) {
const { data, isPending, isError, error } = useQuery({
queryKey: ["user", id],
queryFn: async () => {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error("Failed to load user");
return res.json();
},
});
if (isPending) return <p>Loading...</p>;
if (isError) return <p>{error.message}</p>;
return <h2>{data.name}</h2>;
}The queryKey is an array that uniquely identifies this data in the cache. The queryFn is any async function that returns the data or throws. Back you get data, isPending, isError, error, isFetching, and a refetch function.
Version 5 renamed the status flags, and the distinction matters: isPending means there is no cached data yet, isFetching is true during any request including a silent background refresh, and isLoading is the two combined - a first load with nothing to show. Use isPending for the initial spinner and isFetching for a subtle "refreshing" indicator.
Query keys, caching, and staleTime
The query key is the cache identity. Two components that call useQuery with ["user", 1] share one request and one cached result. That is why the key must include every variable the query function uses:
// Wrong - id is not in the key
useQuery({ queryKey: ["user"], queryFn: () => getUser(id) });
// Right
useQuery({ queryKey: ["user", id], queryFn: () => getUser(id) });With the broken version, switching from user 1 to user 2 shows user 1's data, because both reads hit the same cache entry ["user"]. Include the id and each user gets its own slot, cached independently.
The other surprise is staleTime, which defaults to 0. That means data is considered stale the instant it arrives, so Query refetches it every time the component remounts or the window regains focus. People read this as "the cache is not working." It is - the refetch is a background update, and the cached data shows instantly while it runs. If the data does not change every second, set staleTime to 60000 or more and the refetches stop. A separate setting, gcTime (default 5 minutes), controls how long unused data stays in memory after the last component using it unmounts.
Mutations: changing server data
useQuery is for reading. For writes - POST, PUT, DELETE - use useMutation:
import { useMutation, useQueryClient } from "@tanstack/react-query";
function AddTodo() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (text) =>
fetch("/api/todos", {
method: "POST",
body: JSON.stringify({ text }),
}).then((r) => r.json()),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});
return (
<button onClick={() => mutation.mutate("Learn Query")}>
{mutation.isPending ? "Adding..." : "Add"}
</button>
);
}Call mutation.mutate(value) to run it. The important part is onSuccess: after the write lands, the cached ["todos"] list is out of date, so invalidateQueries marks it stale and Query refetches it automatically. Any component showing that list updates itself. You never call setState and you never manually splice the new item into an array - you tell Query the data changed and let it re-sync.
fetch does not throw on 404
This one quietly disables your error handling. fetch() only rejects on a network failure - DNS, no connection, CORS. An HTTP 404 or 500 resolves normally, with res.ok set to false. So this query function never triggers an error state:
// Broken: a 404 becomes "successful" data
queryFn: async () => {
const res = await fetch(`/api/users/${id}`);
return res.json(); // parses the error page as if it were a user
}Query has no way to know the request failed, so isError stays false and your component renders whatever the error response body happened to contain. You have to check res.ok and throw yourself:
queryFn: async () => {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}Libraries like axios and ky reject on non-2xx responses by default, which is why their users never hit this. If you use bare fetch, the res.ok check is not optional.
