TanStack Query v5: Mastering Server State
AI generated
</>
{ }
React · TanStack Query v5 · Server State · Caching
TanStack Query v5:
Mastering Server State

Manually managed server state in React always leads to the same problems: duplicate requests, stale data, inconsistent loading states and boilerplate spread across hundreds of components. TanStack Query v5 solves this with a declarative caching and synchronization system that turns server responses into a reactive, automatically synchronized part of the UI state.

16 min read useQuery · useMutation · QueryClient · prefetch · optimistic updates TanStack Query 5.x · React 18/19 · TypeScript

1. What sets server state apart from client state

The decisive conceptual difference lies in who owns the data. Client state, whether a modal is open, which tab is active, what is typed into a form field, belongs to the application and only changes through user interactions. Server state, on the other hand, belongs to the backend: a product list, a user profile, the content of a CMS entry. The server can change at any time without the application knowing about it. Any approach that manages server state with useState and useEffect fights against this difference and mostly loses.

Every React developer knows the concrete problems of manual server state handling: loading the same data in two different components creates two parallel requests. After a mutation, other parts of the UI stay stale until the user manually reloads. Error states are handled inconsistently. Loading indicators appear and disappear in an uncoordinated way. TanStack Query v5 solves all of this with a central cache and a subscription mechanism: multiple components that need the same data automatically share a request and the same cache entry.

Version 5 brought important API improvements over v4: the status union now has "pending" instead of "loading", which is semantically more correct. The isLoading flag was complemented by isPending. The object signature for useQuery is now the only form, there is no array parameter anymore. And cacheTime is now correctly called gcTime (garbage collection time), which describes its actual meaning much better.

2. Setting up QueryClient and QueryClientProvider

The QueryClient instance is the heart of TanStack Query v5. It holds the entire cache, manages background refetches and configures global defaults. The instance is created once per application and made available in the component tree through QueryClientProvider. For React Server Components in Next.js there is the HydrationBoundary pattern: the server prefetches data into a dehydratedState, the client rehydrates it and thereby avoids waterfalls.

Global defaults in the QueryClient configuration significantly reduce repetition. staleTime: 60_000 means that all queries consider their data fresh for one minute and trigger no background refetch. retry: 1 reduces automatic retry attempts to one, sensible for production-grade applications where endless retry loops put strain on server infrastructure. The TanStack Query v5 devtools are an indispensable debugging tool and are installed separately as @tanstack/react-query-devtools.


// main.tsx: QueryClient setup with global defaults
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60_000,          // data is fresh for 60 seconds
      gcTime: 5 * 60_000,         // keep unused cache entries for 5 minutes
      retry: 1,                   // one retry on network errors
      refetchOnWindowFocus: true, // re-sync when tab becomes active
    },
    mutations: {
      retry: 0,                   // do not retry mutations automatically
    },
  },
});

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Router />
      {/* Only rendered in development builds */}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

3. useQuery: fetching data with type safety

The useQuery hook is the primary API for all read access. It takes a configuration object with queryKey and queryFn as required fields. The queryKey is an array that uniquely identifies the cache entry and at the same time acts as a dependency list for automatic refetching, similar to the dependency array in useEffect, but with automatic cache management. When a value inside the queryKey changes, TanStack Query automatically starts a new request.

TypeScript generics on useQuery<ResponseType, ErrorType> provide full type safety: data is typed, error has the correct type. The select option enables data transformations directly inside the hook, without a separate transformation step. Memoization of select ensures that downstream computations only rerun when the underlying data actually changes. The destructured return value of the hook exposes all relevant states: data, error, isPending, isError, isFetching and isStale.


// useProducts.ts: typed query hook with select transformation
import { useQuery } from "@tanstack/react-query";

interface Product {
  id: number;
  name: string;
  price: number;
  category: string;
  stock: number;
}

// Centralize query key factory for cache consistency
export const productKeys = {
  all: ["products"] as const,
  list: (filters: Record<string, unknown>) =>
    [...productKeys.all, "list", filters] as const,
  detail: (id: number) => [...productKeys.all, "detail", id] as const,
};

async function fetchProducts(category?: string): Promise<Product[]> {
  const url = category
    ? `/api/products?category=${encodeURIComponent(category)}`
    : "/api/products";
  const res = await fetch(url);
  if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
  return res.json();
}

export function useProducts(category?: string) {
  return useQuery({
    queryKey: productKeys.list({ category }),
    queryFn: () => fetchProducts(category),
    // Transform: only return in-stock items to subscriber
    select: (products) => products.filter((p) => p.stock > 0),
    staleTime: 30_000,
    placeholderData: (prevData) => prevData, // keep old data while reloading
  });
}

4. Caching strategies: understanding staleTime and gcTime

The two most important time parameters of TanStack Query v5 are frequently confused. staleTime determines how long a cache entry is considered fresh. Within this time window, components that mount the same query trigger no network request, they receive the cached data immediately. Once staleTime has elapsed, the data is considered stale, but it is not deleted immediately. Instead, the next trigger event (window focus, component mount, manual invalidation) starts a background refetch while the old data continues to be displayed.

gcTime (formerly cacheTime) determines how long an unused cache entry is kept in memory after all subscribed components have unmounted. A query that no longer has any component using it is removed from the cache once gcTime has elapsed and is fetched fresh the next time it is needed. The typical use case for a high staleTime: reference data such as categories or configuration values that rarely change. For user-specific data like the user's own shopping cart, a low staleTime combined with refetchOnWindowFocus: true is the right choice.

5. useMutation: write operations with error handling

While useQuery is designed for idempotent read access, useMutation takes care of all write operations. The key difference: mutations are not triggered automatically but through an explicit mutate() or mutateAsync() call. The onSuccess, onError and onSettled callbacks enable clean error handling and cache invalidation after the mutation. The pattern onSuccess: () => queryClient.invalidateQueries() is the standard way to refresh affected queries after a write operation.

In TanStack Query v5 there are two levels of mutation callbacks: those defined in the useMutation hook (once per mutation instance) and those passed into the mutate() call (once per call). The latter enable component-specific reactions, for example redirecting to a confirmation page after a successful creation, while the hook-level callbacks are responsible for global actions such as cache invalidation. The mutation's isPending state prevents double clicks and shows loading animations on the submit button.


// useCreateOrder.ts: mutation with cache invalidation and error handling
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { orderKeys } from "./queryKeys";

interface CreateOrderPayload {
  productId: number;
  quantity: number;
  shippingAddress: string;
}

interface Order {
  id: number;
  status: "pending" | "confirmed" | "shipped";
  total: number;
}

async function createOrder(payload: CreateOrderPayload): Promise<Order> {
  const res = await fetch("/api/orders", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(payload),
  });
  if (!res.ok) {
    const err = await res.json();
    throw new Error(err.message ?? `HTTP ${res.status}`);
  }
  return res.json();
}

export function useCreateOrder() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: createOrder,
    onSuccess: (newOrder) => {
      // Invalidate order list so it refetches in the background
      queryClient.invalidateQueries({ queryKey: orderKeys.all });
      // Directly populate the new order's detail cache entry
      queryClient.setQueryData(orderKeys.detail(newOrder.id), newOrder);
    },
    onError: (error: Error) => {
      console.error("[createOrder] failed:", error.message);
    },
  });
}

6. Optimistic updates: instant UI feedback

Optimistic updates are the pattern where the UI reacts immediately to a user action, before the server response arrives. This follows from a simple UX consideration: in 95% of cases a mutation does not fail, and the user waits unnecessarily. TanStack Query v5 offers a structured workflow for this in the onMutate callback: save the current cache state, update the cache optimistically, and restore the saved state in the onError callback if something goes wrong.

The onMutate pattern starts with queryClient.cancelQueries() to stop any running background refetches, otherwise a stale server response could overwrite the optimistic update. The current state is then saved with queryClient.getQueryData() and the cache is optimistically updated with queryClient.setQueryData(). The saved context is passed to onError and written back with queryClient.setQueryData() on failure. Finally, onSettled invalidates the query so the actual server data gets loaded.

7. Prefetching and query invalidation

Prefetching is the technique of loading data before the user needs it. TanStack Query v5 provides queryClient.prefetchQuery() for this, which uses the same mechanism as useQuery but without mounting a component. Typical use points: on hovering over a link, when showing a tooltip preview, or when loading a route. With Next.js and the server rendering pattern, the server prefetches all needed data before HTML rendering, serializes the cache with dehydrate() and sends it along as JSON. The client rehydrates it with HydrationBoundary and immediately has all the data without a network round trip.

Query invalidation with queryClient.invalidateQueries() is the cleanest way to ensure data consistency after mutations. The method marks matching cache entries as stale and triggers a background refetch if the query is actively subscribed. The queryKey filter uses hierarchical matching: { queryKey: ['products'] } invalidates all queries whose key starts with ['products'], so both the product list and individual product details. The query key factory pattern (all keys defined centrally) makes this hierarchical matching precise and maintainable.

8. Infinite queries and pagination

useInfiniteQuery is the specialized API for pagination and infinite scroll in TanStack Query v5. Unlike useQuery, it accumulates server responses across multiple pages in the cache. The getNextPageParam function extracts the parameter for the next page from each server response, usually a cursor, an offset, or a page number. fetchNextPage() triggers the next request and appends the result to data.pages, without overwriting previous pages.

Rendering the results in a flat list requires a data.pages.flatMap(page => page.items) that merges all pages into a single list. hasNextPage and isFetchingNextPage control the scroll trigger: an intersection observer on the last list element calls fetchNextPage() once it becomes visible. For traditional pagination with explicit page numbers, a simple useQuery with the page index in the queryKey and placeholderData: (prevData) => prevData is recommended instead, to avoid flicker between page transitions.

9. TanStack Query v5 vs. a manual useEffect solution

A direct comparison shows why the manual approach with useEffect for server state always fails in the medium term, and which concrete problems TanStack Query v5 solves.

Aspect Manual (useState + useEffect) TanStack Query v5 Difference
Duplicate requests One request per component Automatically deduplicated No extra network traffic
Stale data Manual reload after mutation Automatic invalidation Consistency without effort
Background sync Not available refetchOnWindowFocus Always current data
Loading states 3+ separate useState flags status, isPending, isFetching Consistent API, fewer bugs
SSR / prefetching Complex manual solution dehydrate / HydrationBoundary No waterfall on first render

The comparison is not fair for simple applications: whoever has a single API endpoint that never changes does not need TanStack Query. From the point where data comes from multiple endpoints, mutations require cache invalidation, or SSR plays a role, the manual approach becomes a source of endless bugs. In this context, TanStack Query pays off starting from the second complex component.

Mironsoft

React architecture, TanStack Query and scalable frontend systems

Server state that never falls out of sync again?

We migrate existing React applications to TanStack Query v5, from query key architecture through mutation strategies to SSR integration with Next.js or Remix.

Code analysis

Analyze existing useEffect data fetching and define a migration path to TanStack Query v5

Query architecture

Design a query key factory, caching strategy and mutation pattern for your data model

SSR integration

dehydrate/HydrationBoundary with the Next.js App Router or Remix for waterfall-free rendering

10. Summary

TanStack Query v5 is today's standard for server state management in React applications. useQuery with a typed queryFn and a query key factory gives full control over caching and refetching. staleTime and gcTime determine how aggressively or conservatively the library handles network requests. useMutation with onSuccess invalidation keeps cache and server in sync. Optimistic updates make interactions feel instant for the user. Prefetching and dehydrate eliminate waterfalls on first render.

The query key factory pattern is the glue that holds everything together: all cache keys are defined centrally, structured hierarchically, and used consistently throughout the entire project. Invalidations hit exactly the right entries. Prefetches populate exactly the cache that components will read. TypeScript generics ensure that data and error always carry the correct types. With this architecture, server state in React scales from single data fetches to complex multi-endpoint applications with SSR.

TanStack Query v5, the essentials at a glance

Query keys

Array-based, hierarchical, defined centrally as a factory. Changes in the key trigger automatic refetching, similar to useEffect dependencies.

staleTime vs. gcTime

staleTime: how long data is considered fresh. gcTime: how long unused cache is kept. Both configurable separately per query.

Mutations + invalidation

onSuccess: () => queryClient.invalidateQueries() is the standard pattern. Hierarchical matching invalidates all affected entries at once.

Optimistic updates

onMutate: cancelQueries then getQueryData then setQueryData. onError: setQueryData back. onSettled: invalidateQueries for fresh server data.

11. FAQ: TanStack Query v5 and server state

1isPending vs. isLoading in v5?
isLoading no longer exists in v5. isPending: no cached value and the query is running. isFetching: a fetch is running, cached data may already be visible.
2useQuery vs. useSuspenseQuery?
useSuspenseQuery integrates with React Suspense. The component suspends while loading, the Suspense boundary shows the fallback. Simplifies components, data is always defined.
3Cross-tab synchronization?
The broadcastQueryClient plugin sends invalidations via BroadcastChannel. A mutation in tab A automatically triggers a refetch in tab B.
4Does TanStack Query replace Redux?
For server state, yes. For client state (UI, forms), Zustand/Redux still makes sense. Combining TanStack Query with Zustand covers both areas without overlap.
5Disabling a query?
enabled: false prevents automatic execution. enabled: term.length >= 3 only starts the search once 3 characters have been typed, a classic search field pattern.
6Multiple components, same query?
Automatic deduplication. All subscribers with the same queryKey share one request and one cache entry. No duplicate fetching.
7Next.js App Router integration?
Server Component: prefetchQuery then dehydrate then the HydrationBoundary prop. Client Component: useQuery with the same key gives instant data, no waterfall.
8Choosing staleTime by data type?
Reference data: 5 to 60 min. User data: 30 to 60 s. Real-time: 0 s with refetchInterval. A global default of 60 s is a good starting point.
9Debugging cache issues?
ReactQueryDevtools shows all cache entries, status (fresh/stale/fetching), observer count and a data preview. The most effective tool for query debugging.
10TanStack Query without React?
Framework-agnostic. Official adapters for Vue, Solid, Svelte, Angular. QueryClient and caching are framework-independent. The React hooks are just an adapter.