In modern web application engineering, one of the most persistent architectural anti-patterns has been the attempt to manage remote server data with client-side state managers (Redux, MobX, React Context).
Server state (Server State) differs fundamentally from client state (Client State). TanStack Query v5 addresses remote data synchronization: it is not a state manager in the traditional sense, but an asynchronous distributed cache featuring automatic revalidation, network request deduplication, and granular lifecycle garbage collection.
Attempting to house remote backend data inside conventional client stores introduces three systemic flaws into production applications:
useEffect, lacking deduplication, the browser fires five identical HTTP requests in parallel.isLoading, isError, inactive cache expiration timers, in-flight request cancellation on route unmount, and optimistic rollbacks requires hundreds of lines of fragile boilerplate.Interactions between React components and the server cache rest upon four foundational architectural pillars:
useQuery simultaneously with identical query keys, exactly one network call is initiated. Every consuming component subscribes to the same shared promise inside QueryCache.fresh, it resolves instantly from memory without touching the network. Once stale, it resolves immediately from memory while quietly triggering a background revalidation request.['orders', 'detail', id] empowers both precise point updates and bulk cascading invalidations.// TanStack Query Key Hierarchy
['orders'] // All order-related queries
['orders', 'list'] // All order list queries
['orders', 'list', { status }] // Filtered list query
['orders', 'detail', orderId] // Specific order recordA Next.js Server Component instantiates an isolated QueryClient per incoming request, preventing cross-tenant memory leaks.
Executes upstream queries concurrently on the Node.js/Edge backend, awaiting resolution before streaming HTML.
Serializes the populated server cache into an immutable JSON tree passed into the React tree via HydrationBoundary.
Client-side useQuery calls read primed records synchronously from the dehydrated context, avoiding initial loading spinners.
Once staleTime expires, TanStack Query triggers background revalidation on window focus or network reconnect.
What happens if a useQuery hook is configured with staleTime: 60000 (1 minute) and gcTime: 10000 (10 seconds), after which a user navigates to another page and returns after 20 seconds?
refetchOnWindowFocus) and cross-tab synchronization.prefetchQuery) within Server Components and dehydrating into <HydrationBoundary> to eliminate Cumulative Layout Shift (CLS) and achieve instant First Contentful Paint.AbortSignal cancellation.useInfiniteQuery (getNextPageParam, getPreviousPageParam) while preserving scroll coordinates upon back-navigation.Scattering loose array literals like ['users', userId, 'posts'] across UI components breeds typographical errors and incomplete cache invalidations. Production systems enforce the Query Key Factory pattern:
/**
* Strongly typed hierarchical cache key factory.
* Enforces safe granular updates and prefix-based cascading invalidations.
*/
export const orderKeys = {
all: ['orders'] as const,
lists: () => [...orderKeys.all, 'list'] as const,
list: (filters: { status?: string; page?: number }) =>
[...orderKeys.lists(), filters] as const,
details: () => [...orderKeys.all, 'detail'] as const,
detail: (id: string) => [...orderKeys.details(), id] as const,
metrics: (dateRange: { from: string; to: string }) =>
[...orderKeys.all, 'metrics', dateRange] as const,
};
// Invalidation examples:
// queryClient.invalidateQueries({ queryKey: orderKeys.all }) -> invalidates all order queries
// queryClient.invalidateQueries({ queryKey: orderKeys.lists() }) -> invalidates only lists
// queryClient.invalidateQueries({ queryKey: orderKeys.detail('42') }) -> invalidates order #42In Next.js App Router, data is fetched upstream inside a Server Component using a request-scoped QueryClient, serialized via dehydrate, and handed to the client tree using HydrationBoundary:
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
import { orderKeys } from '@/api/queryKeys';
import { fetchOrderById } from '@/api/orders';
import { OrderDetailsView } from '@/components/OrderDetailsView';
interface PageProps {
params: Promise<{ id: string }>;
}
export default async function OrderPage({ params }: PageProps) {
const { id } = await params;
// Create an isolated QueryClient per incoming request (Request-Scoped)
const queryClient = new QueryClient();
// Prefetch data into the server cache (Node.js/Edge runtime)
await queryClient.prefetchQuery({
queryKey: orderKeys.detail(id),
queryFn: ({ signal }) => fetchOrderById(id, signal),
});
return (
// Dehydrated state is provided to client components without request waterfalls
<HydrationBoundary state={dehydrate(queryClient)}>
<OrderDetailsView orderId={id} />
</HydrationBoundary>
);
}'use client';
import { useQuery } from '@tanstack/react-query';
import { orderKeys } from '@/api/queryKeys';
import { fetchOrderById } from '@/api/orders';
export function OrderDetailsView({ orderId }: { orderId: string }) {
// Data resolves INSTANTLY from the HydrationBoundary cache with zero skeleton flicker!
const { data: order, status, isFetching } = useQuery({
queryKey: orderKeys.detail(orderId),
queryFn: ({ signal }) => fetchOrderById(orderId, signal),
staleTime: 60 * 1000, // Data remains fresh for 1 minute
});
if (status === 'pending') {
return <div className="animate-pulse p-4">Loading skeleton...</div>;
}
if (status === 'error') {
return <div className="text-red-400 p-4">Failed to load order.</div>;
}
return (
<div className="p-6 border border-border rounded-xl bg-bg-surface">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold font-sans">Order #{order.id}</h1>
{isFetching && (
<span className="text-xs text-accent-cyan animate-pulse">
Syncing...
</span>
)}
</div>
<p className="mt-2 text-text-muted">Status: {order.status}</p>
<p className="text-text-muted">Total: ${order.totalAmount}</p>
</div>
);
}The Optimistic Update pattern is implemented across three discrete lifecycle steps in useMutation:
onMutate: Cancel in-flight queries, snapshot existing cache entries, and synchronously apply optimistic data via setQueryData.onError: Restore previous cache snapshot if network fails.onSettled: Invalidate affected keys to reconcile with authoritative database state.Calling queryClient.cancelQueries prevents a late-resolving background refetch from overwriting optimistic UI.
Snapshot current cache state via getQueryData and save to context. Synchronously write optimistic updates with setQueryData.
Dispatch HTTP request passing AbortSignal. The user already experiences zero-latency UI updates.
If the network call fails (500, Offline, 403), onError extracts previousData from context and restores the original cache state.
onSettled executes unconditionally (success or error), triggering invalidateQueries to sync with database truth.
Why is it mandatory in onMutate to execute await queryClient.cancelQueries({ queryKey }) BEFORE invoking queryClient.setQueryData?
import { useMutation, useQueryClient } from '@tanstack/react-query';
export type TaskStatus = 'TODO' | 'IN_PROGRESS' | 'DONE';
export interface TaskItem {
readonly id: string;
readonly title: string;
readonly status: TaskStatus;
readonly updatedAt: string;
}
export interface UpdateTaskPayload {
readonly taskId: string;
readonly nextStatus: TaskStatus;
}
interface MutationContext {
previousTasks?: TaskItem[];
}
export const taskKeys = {
all: ['tasks'] as const,
lists: () => [...taskKeys.all, 'list'] as const,
};
async function mutateTaskStatusApi(
payload: UpdateTaskPayload,
signal?: AbortSignal
): Promise<TaskItem> {
const response = await fetch(`/api/tasks/${payload.taskId}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: payload.nextStatus }),
signal,
});
if (!response.ok) {
throw new Error(`Failed to update task status: HTTP ${response.status}`);
}
return (await response.json()) as TaskItem;
}
export function useUpdateTaskStatus() {
const queryClient = useQueryClient();
return useMutation<TaskItem, Error, UpdateTaskPayload, MutationContext>({
mutationFn: (payload) => mutateTaskStatusApi(payload),
// 1. Optimistic Update Phase
onMutate: async (newTodoPayload) => {
// Cancel active list refetches to prevent race condition overrides
await queryClient.cancelQueries({ queryKey: taskKeys.lists() });
// Snapshot existing cache state for rollback guarantee
const previousTasks = queryClient.getQueryData<TaskItem[]>(taskKeys.lists());
// Optimistically write updated status
if (previousTasks) {
queryClient.setQueryData<TaskItem[]>(
taskKeys.lists(),
previousTasks.map((task) =>
task.id === newTodoPayload.taskId
? {
...task,
status: newTodoPayload.nextStatus,
updatedAt: new Date().toISOString(),
}
: task
)
);
}
// Return context containing previous state for onError
return { previousTasks };
},
// 2. Rollback Phase
onError: (_err, _variables, context) => {
if (context?.previousTasks) {
queryClient.setQueryData(taskKeys.lists(), context.previousTasks);
}
},
// 3. Final Reconciliation Phase
onSettled: () => {
void queryClient.invalidateQueries({ queryKey: taskKeys.lists() });
},
});
}Declaring const queryClient = new QueryClient() inside a React component re-creates the entire cache instance on every render. All previously cached responses are erased, causing infinite re-fetching loops.
// ❌ ANTIPATTERN: Fresh QueryClient created on every render cycle!
export function BadAppProvider({ children }: { children: React.ReactNode }) {
const queryClient = new QueryClient(); // CACHE DESTROYED ON EVERY RENDER!
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
// ✅ PRODUCTION CONTRACT: Singleton instance on client, per-request instance on server
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
},
},
});
}
let browserQueryClient: QueryClient | undefined = undefined;
function getQueryClient() {
if (typeof window === 'undefined') {
// Server: Always create a fresh client per request
return makeQueryClient();
}
// Browser: Reuse persistent singleton client
if (!browserQueryClient) browserQueryClient = makeQueryClient();
return browserQueryClient;
}
export function GoodAppProvider({ children }: { children: React.ReactNode }) {
const queryClient = getQueryClient();
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}If queryFn neglects the provided signal argument, the browser continues downloading large payloads even after the user has navigated away or unmounted the component.
// ❌ ANTIPATTERN: Request cannot be aborted when unmounted
useQuery({
queryKey: ['users', id],
queryFn: () => fetch(`/api/users/${id}`).then((res) => res.json()),
});
// ✅ PRODUCTION CONTRACT: Pass AbortSignal to native fetch
useQuery({
queryKey: ['users', id],
queryFn: ({ signal }) =>
fetch(`/api/users/${id}`, { signal }).then((res) => {
if (!res.ok) throw new Error('Network error');
return res.json();
}),
});Setting gcTime shorter than staleTime causes the garbage collector to evict inactive queries from memory while their data is still considered fresh. When the component remounts, users encounter loading spinners instead of instantaneous cached views.
// ❌ ANTIPATTERN: gcTime purges cache after 10s, despite 5m fresh window!
useQuery({
queryKey: ['analytics'],
queryFn: fetchAnalytics,
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 1000, // 10 seconds (Critical configuration flaw!)
});
// ✅ PRODUCTION CONTRACT: gcTime MUST ALWAYS be greater than or equal to staleTime
useQuery({
queryKey: ['analytics'],
queryFn: fetchAnalytics,
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 15 * 60 * 1000, // 15 minutes
});Attempting to modify the returned data object in-place results in untracked state corruption: React skips component re-renders, Fiber references remain unchanged, and rollbacks become impossible.
// ❌ ANTIPATTERN: Direct in-place mutation of cache reference
const { data: user } = useQuery({ queryKey: ['user', id], queryFn: fetchUser });
const onClick = () => {
if (user) {
user.name = 'New Name'; // FORBIDDEN: Mutating immutable cache object!
}
};
// ✅ PRODUCTION CONTRACT: Immutable update via queryClient.setQueryData
const queryClient = useQueryClient();
const onClickGood = () => {
queryClient.setQueryData(['user', id], (oldUser: User | undefined) => {
if (!oldUser) return undefined;
return { ...oldUser, name: 'New Name' };
});
};