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.
Table of Contents
- 1. What sets server state apart from client state
- 2. Setting up QueryClient and QueryClientProvider
- 3. useQuery: fetching data with type safety
- 4. Caching strategies: understanding staleTime and gcTime
- 5. useMutation: write operations with error handling
- 6. Optimistic updates: instant UI feedback
- 7. Prefetching and query invalidation
- 8. Infinite queries and pagination
- 9. TanStack Query v5 vs. a manual useEffect solution
- 10. Summary
- 11. FAQ
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.