I14 · Part 2 · Intermediate
Server state with TanStack Query
Treat API data as a cache you do not own, and most of your state problems disappear.
09On this page
The distinction the library is built on
| Client state | Server state | |
|---|---|---|
| Owned by | You | Somebody else's database |
| Can go stale | No | Constantly |
| Needs loading and error states | No | Always |
| Shared between components | Sometimes | Usually the same copy |
| Examples | Is the menu open, the current tab | Users, orders, anything fetched |
Putting server state in useState means reimplementing caching,
deduplication, revalidation and invalidation in every component that fetches.
That is the entire argument.
The basic query
const { data, isPending, isError, error } = useQuery({
queryKey: ["user", userId],
queryFn: () => fetch("/api/users/" + userId).then((r) => r.json()),
});
if (isPending) return <Skeleton />;
if (isError) return <ErrorPanel error={error} />;
return <Profile user={data} />;
Three components asking for ["user", 7] make one request
and share the result. Navigate away and back and the cached value renders
immediately while a refetch happens quietly behind it.
The query key is the cache key
["todos"] // all todos
["todos", { status: "done" }] // a filtered list
["todos", todoId] // one todo
Everything the query depends on goes in the key. Change the key and it is a different entry — which means refetching is automatic, and the race condition cannot happen, because responses are stored against the key they were requested with rather than written into shared state.
Keys are hierarchical, and that is what makes invalidation ergonomic:
invalidating ["todos"] invalidates every key that starts with it.
staleTime is the setting that matters
useQuery({ queryKey, queryFn, staleTime: 5 * 60 * 1000 }); // fresh for 5 minutes
| Setting | Means | Default |
|---|---|---|
staleTime | How long data is considered fresh — no refetch at all | 0 |
gcTime | How long unused data stays in memory before eviction | 5 min |
refetchOnWindowFocus | Revalidate when the tab regains focus | true |
With the default staleTime: 0, every mount revalidates. People
meet the library, see a request on every navigation, and conclude it is
chatty. It is doing exactly what it was told; a country list can have a
staleTime of an hour, a live price cannot.
Mutations and invalidation
const queryClient = useQueryClient();
const { mutate, isPending } = useMutation({
mutationFn: (todo) => api.post("/todos", todo),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["todos"] }); // refetch the lists
},
});
mutate({ title: "Write the chapter" });
Invalidation is the half people get wrong. After a create, which keys are now
out of date? The list, certainly — but also any filtered list, any
count, any dashboard summary. Hierarchical keys mean one
invalidateQueries(["todos"]) covers all of them, which is why key
design is worth five minutes of thought at the start.
Optimistic updates, with the rollback
useMutation({
mutationFn: toggleTodo,
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: ["todos"] }); // stop a refetch overwriting us
const previous = queryClient.getQueryData(["todos"]);
queryClient.setQueryData(["todos"], (old) =>
old.map((t) => (t.id === id ? { ...t, done: !t.done } : t))
);
return { previous }; // context for onError
},
onError: (err, id, ctx) => {
queryClient.setQueryData(["todos"], ctx.previous); // roll back
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["todos"] }); // reconcile either way
},
});
The checkbox ticks instantly, and if the request fails it un-ticks and the error is shown. Naming the rollback in an interview is what separates having read about optimistic updates from having shipped one.
Paginated and infinite lists
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ["todos"],
queryFn: ({ pageParam }) => fetchPage(pageParam),
initialPageParam: 0,
getNextPageParam: (last) => last.nextCursor ?? undefined,
});
For classic pagination, placeholderData: keepPreviousData keeps
the current page on screen while the next loads, so the table does not
collapse to a spinner between pages.
Prefetching removes the spinner
<Link
onMouseEnter={() => queryClient.prefetchQuery({ queryKey: ["user", id], queryFn })}
to={"/users/" + id}
/>
By the time the click registers, the data is usually there. It is one of the cheapest perceived-performance wins available.
SWR, and where this fits with frameworks
SWR is the smaller alternative with the same core ideas — a key, a fetcher, stale-while-revalidate — and less surface area. Either is a good choice; TanStack Query has more built in for mutations and pagination.
In a framework with Server Components or loaders, most initial data should come from the server instead, and the query cache handles what stays interactive — polling, infinite scroll, anything after a user action. The two compose: the server can hydrate the cache so the first render has data and later interactions still get caching and invalidation.
Interview answer, one sentence
"Server state is a cache of data you do not own, so it needs staleness,
deduplication, revalidation and invalidation — none of which
useState provides. The query key is the cache key, so a changed
key refetches automatically and the response race disappears; the hard part
left is deciding which keys a mutation invalidates."
Opens in the editor: write it, run it, and check it against real tests.
All 66 for this level →Finished reading?
0 of 57 chapters read in this series