TanStack Query v5: Server State as First-Class Citizen
TanStack Query is not “a way to call fetch from React.” It is a cache coordinator: it knows
when data is fresh, stale, in-flight, or orphaned; it merges identical requests; it gives mutations
a structured place to invalidate or optimistically patch related keys. In v5 the API doubles down on
uniformity—every hook takes a single options object—and on TypeScript, including the
queryOptions helper that lets you define a query once and reuse it with full inference in hooks,
loaders, and tests.
If you are still storing API payloads in global client stores by default, you are likely reimplementing deduplication and staleness rules by hand. Query’s job is to make server-derived state feel as boring as props.
queryOptions: one definition, many call sites
Colocating queryKey, queryFn, and timing options in a queryOptions object gives you a single
source of truth. Components, prefetchers, and router loaders all consume the same artifact, so
refactors do not drift.
import { useQuery, useMutation, useQueryClient, queryOptions } from "@tanstack/react-query";
interface Post {
id: string;
title: string;
}
interface NewPost {
title: string;
}
declare function fetchPosts(): Promise<Post[]>;
declare function createPost(newPost: NewPost): Promise<Post>;
const postsQueryOptions = queryOptions({
queryKey: ["posts"],
queryFn: fetchPosts,
staleTime: 5 * 60 * 1000,
});
function PostsList() {
const { data, isLoading, error } = useQuery(postsQueryOptions);
return null;
}
declare const queryClient: import("@tanstack/react-query").QueryClient;
await queryClient.prefetchQuery(postsQueryOptions);
staleTime answers “how long may we treat this as fresh without refetching?” A default of 0 means
data becomes stale immediately—fine for highly dynamic dashboards, wasteful for reference data. Pair
that with thoughtful keys so invalidation stays precise rather than “refetch the world.”
staleTime versus gcTime
v5 renamed the old cacheTime to gcTime to stress garbage collection of unused cache
entries, distinct from staleness. Stale data may still render while a background refetch
runs; GC decides how long to keep unused data in memory after the last subscriber unmounts.
Defaults skew toward responsiveness (staleTime 0) and reasonable retention (gcTime on the order
of minutes)—tune both for your UX and memory constraints, not just the first example you copy.
Optimistic updates with a safety net
Mutations should assume failure. The classic pattern: snapshot previous cache, apply an optimistic patch, roll back on error, and reconcile on settle—usually via invalidation so server truth wins.
import { useMutation, useQueryClient } from "@tanstack/react-query";
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (newPost: NewPost) => createPost(newPost),
onMutate: async (newPost) => {
await queryClient.cancelQueries({ queryKey: ["posts"] });
const previousPosts = queryClient.getQueryData<Post[]>(["posts"]);
queryClient.setQueryData<Post[]>(["posts"], (old = []) => [...old, { id: "temp", ...newPost }]);
return { previousPosts };
},
onError: (_err, _newPost, context) => {
queryClient.setQueryData(["posts"], context?.previousPosts);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["posts"] });
},
});
TypeScript pays off in context: threading previousPosts through onMutate’s return value gives
onError a typed rollback payload. Keep keys and option objects centralized so optimistic patches
do not target a slightly different key than the list query.
Infinite queries and cursor pagination
Lists that grow with “load more” map cleanly onto useInfiniteQuery: each page fetch receives
pageParam, and you expose fetchNextPage / hasNextPage to the UI.
import { useInfiniteQuery } from "@tanstack/react-query";
declare function fetchPosts(args: { cursor: string | undefined }): Promise<{ nextCursor?: string }>;
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ["posts", "infinite"],
queryFn: ({ pageParam }) => fetchPosts({ cursor: pageParam }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
Cursor shapes differ by API; the critical part is a total order your backend honors and a
getNextPageParam that stops returning when exhausted.
Status-narrowing for ergonomic TypeScript
v5’s TypeScript story includes discriminated status on query results. After a narrow check,
data is non-optional without manual guards—a small DX win that compounds across large trees.
const query = useQuery(postsQueryOptions);
if (query.isSuccess) {
query.data;
}
Project requirement: TypeScript 5.4+ aligns with the library’s typings; staying current avoids fighting incompatible utility types.
RSC, prefetch, and hydration
Server Components and route loaders can prefetch query data before the client needs it, then hydrate or resume with a warm cache. The exact wiring depends on your framework adapter, but the principle is stable: push as much server truth to the edge of the client cache as possible, and let Query manage transitions, retries, and deduplication after hydration.
TanStack Query v5 rewards a disciplined split: server state in the cache, navigation state in
the router, local interaction in components, and global UI in something small like Zustand
when necessary. Master queryOptions, staleness tuning, and optimistic mutation patterns, and most
“state management” discussions shrink to their rightful size.
Keys, structural sharing, and shared defaults
Query keys are your schema. Stable, hierarchical keys make invalidation predictable: ["posts"]
for the collection, ["posts", postId] for the member, ["posts", { filter }] when filters
participate in identity. Avoid stuffing unbounded objects into keys without serialization
discipline—two logically equal filters should produce the same key. v5 continues to apply
structural sharing when updates arrive so referential identity of unchanged subtrees often
persists, which matters when memoized children depend on reference equality. For cross-cutting
behavior, configure a shared QueryClient with defaultOptions for retries, staleTime, and error
reporting, but resist turning defaults into a hiding place for one-off policies; explicit options at
the queryOptions layer stay easier to audit in code review.
TypeScript 5.4+ and hook ergonomics
The v5 move to a single object argument per hook is not just stylistic—it simplifies overload
resolution and makes composition with wrappers and factories more regular. When you outgrow inline
hooks, factory helpers built on queryOptions keep generics flowing into both useQuery and
prefetchQuery without duplicating keys. If a query’s data type drifts, fix the queryFn return
type first; patching consumers with assertions multiplies debt. The isSuccess narrowing pattern
scales especially well inside feature modules where early returns are already the house style—let
the discriminated union do the narrowing work you used to do with manual undefined checks.