TanStack Query
Queries, mutations, optimistic updates, query invalidation.
Fetching data with useEffect + useState looks easy until you realize you need: loading states, error handling, retries, caching, deduplication, refetching when the window refocuses, pagination, optimistic updates, and request cancellation. That's a 500-line file. Or you can install TanStack Query and get all of it in three lines.
Why this exists
TanStack Query (formerly React Query) is the de facto answer for server state in React. Server state has very different rules from client state:
- It lives somewhere you don't control (a server).
- It can become stale at any moment.
- Multiple components often need the same data.
- You need to invalidate it when things change.
useState + useEffect is the wrong tool for any of that.
useQuery: the basic shape
import { useQuery } from "@tanstack/react-query";
function UserProfile({ id }) {
const { data, isPending, error } = useQuery({
queryKey: ["user", id],
queryFn: () => fetch(`/api/users/${id}`).then((r) => r.json()),
});
if (isPending) return <Spinner />;
if (error) return <ErrorPanel error={error} />;
return <h1>{data.name}</h1>;
}That's the whole pattern. You give it two things:
- A
queryKey(an array) that uniquely identifies this piece of data. - A
queryFnthat returns a promise resolving to the data.
TanStack Query handles loading state, errors, retries, caching by key, and deduplication (ten components asking for the same key fire one request).
Query keys: think of them as cache addresses
The key is how TanStack identifies a query. Two components with the same key share data and a single network request. Different keys = different cache entries.
// Same data, one request
useQuery({ queryKey: ["users"], queryFn: getUsers });
useQuery({ queryKey: ["users"], queryFn: getUsers });
// Per-id user queries
useQuery({ queryKey: ["user", id], queryFn: () => getUser(id) });
// Lists with filters as part of the key
useQuery({ queryKey: ["todos", { status: "open", page: 1 }], queryFn: ... });Convention: start broad, narrow with details. ["users"] for the list, ["user", id] for one, ["users", { /* filters */ }] for filtered lists. The key's prefix lets you invalidate everything under it at once.
staleTime vs gcTime
Two different timers, and they confuse everyone.
staleTime: how long the data is considered "fresh." While fresh, TanStack returns the cached value without a background refetch. Default:0(everything is stale instantly).gcTime(formerlycacheTime): how long unused cache entries stick around in memory before being garbage-collected. Default: 5 minutes.
Set staleTime: 60_000on data that doesn't change often, like a user profile. You'll cut your network traffic in half.
useQuery({
queryKey: ["user", id],
queryFn: () => getUser(id),
staleTime: 5 * 60_000, // fresh for 5 minutes
});Mutations: useMutation
Queries read. Mutations write. POST, PUT, DELETE all use useMutation:
const queryClient = useQueryClient();
const addTodo = useMutation({
mutationFn: (text) => fetch("/api/todos", {
method: "POST",
body: JSON.stringify({ text }),
}).then((r) => r.json()),
onSuccess: () => {
// Invalidate the list so it refetches
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});
// In a handler
addTodo.mutate("Buy milk");The pattern: mutate → invalidate. After a successful mutation, call invalidateQueries on the lists that might have changed. Anything currently rendered with that key refetches automatically.
Optimistic updates with onMutate
Want the UI to update beforethe server confirms? That's what optimistic updates are. onMutate runs immediately; you tweak the cache directly. If the server fails, you roll back.
useMutation({
mutationFn: (todo) => updateTodo(todo),
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 }; // context for rollback
},
onError: (err, newTodo, ctx) => {
queryClient.setQueryData(["todos"], ctx.previous);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});Lots of code for a click feeling instant. In 2026 React 19 also has useOptimistic which is simpler for many cases (see the next chapter).
<QueryClientProvider client={client}>. Every useQuery and useMutation below it shares the same cache. Most apps need exactly one client.Try it: a working query app
Below is a tiny app using a mock in-memory "server." Watch the network counter: queries dedupe, refetch-on-focus, and the cache keeps results between view toggles. Try adding a delay to see loading states.
Quiz
What does a query key do?
Recap
- Server state needs its own tool. TanStack Query is the React ecosystem standard.
useQuerytakes aqueryKeyandqueryFn. It handles loading, error, retry, caching, dedupe.staleTimecontrols how long data is considered fresh. Tune it per query type.useMutationfor writes. After success, callinvalidateQueries.onMutate+setQueryData= optimistic updates. Roll back inonError.- Stop writing fetch effects. This is the answer.