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.