s and predictable server-state synchronization.
Core Solution
Implementing React Query requires shifting from imperative fetching to declarative query orchestration. The architecture centers on three primitives: QueryClient (cache engine), useQuery (data consumption), and useMutation (data modification). Below is a production-ready implementation pattern.
Step 1: Initialize the Query Client
Configure the cache engine at the application root. Sensible defaults prevent cache fragmentation and control memory lifecycle.
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60, // 1 minute: data remains fresh without refetch
gcTime: 1000 * 60 * 10, // 10 minutes: cache eviction window
retry: 2,
refetchOnWindowFocus: false,
},
mutations: {
retry: 0,
},
},
});
export function AppProviders({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
Step 2: Define Typed Query Options
Extract query configuration into reusable, type-safe modules. This enables consistency, testability, and SSR hydration readiness.
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
export interface User {
id: string;
name: string;
email: string;
role: 'admin' | 'user';
}
export const userKeys = {
all: ['users'] as const,
detail: (id: string) => [...userKeys.all, 'detail', id] as const,
list: (filters: { page: number; limit: number }) => [...userKeys.all, 'list', filters] as const,
};
export function useUser(id: string) {
return useQuery({
queryKey: userKeys.detail(id),
queryFn: () => api.users.get(id),
enabled: !!id,
});
}
export function useUserList(filters: { page: number; limit: number }) {
return useQuery({
queryKey: userKeys.list(filters),
queryFn: () => api.users.list(filters),
keepPreviousData: true, // prevents UI flicker during pagination
});
}
Step 3: Handle Mutations with Cache Invalidation
Mutations modify server state. They must invalidate or optimistically update the cache to maintain consistency.
export function useUpdateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<User> }) =>
api.users.update(id, data),
onMutate: async ({ id, data }) => {
// Cancel outgoing refetches to avoid race conditions
await queryClient.cancelQueries({ queryKey: userKeys.detail(id) });
// Snapshot current cache
const previous = queryClient.getQueryData<User>(userKeys.detail(id));
// Optimistically update
if (previous) {
queryClient.setQueryData<User>(userKeys.detail(id), { ...previous, ...data });
}
return { previous };
},
onError: (_err, _vars, context) => {
// Rollback on failure
if (context?.previous) {
queryClient.setQueryData(userKeys.detail(context.previous.id), context.previous);
}
},
onSettled: (_data, _error, { id }) => {
// Invalidate to fetch authoritative server state
queryClient.invalidateQueries({ queryKey: userKeys.detail(id) });
},
});
}
Architecture Decisions & Rationale
- Query Keys as Arrays: Arrays enable hierarchical cache matching.
['users', 'detail', '123'] allows bulk invalidation via ['users'] while preserving granular entries.
- Stale Time vs GC Time:
staleTime controls when data is considered fresh. gcTime controls when unused cache entries are purged. Separating them prevents premature eviction while maintaining memory hygiene.
- Optimistic Updates with Rollback:
onMutate snapshots state before applying changes. onError restores it. This pattern eliminates loading spinners for predictable operations while preserving data integrity.
keepPreviousData for Pagination: Prevents UI flash when navigating pages. The previous result remains visible until the new query resolves.
- Centralized
QueryClient Configuration: Global defaults reduce per-query boilerplate and enforce consistent retry, stale, and garbage collection policies across the application.
Pitfall Guide
1. Over-fetching Due to Missing staleTime
Mistake: Leaving staleTime at 0 causes refetches on every mount or window focus.
Impact: Network saturation, rate limit violations, and degraded perceived performance.
Fix: Set staleTime to match data volatility. Static metadata: 5-10 minutes. Real-time dashboards: 10-30 seconds. Use refetchOnWindowFocus: false unless explicitly required.
2. Query Key Instability
Mistake: Using dynamically generated objects or non-serializable values in query keys.
Impact: Cache misses, duplicate requests, and memory leaks.
Fix: Keys must be serializable and stable. Use primitive arrays. Avoid { id: user.id, timestamp: Date.now() }. Prefer ['users', id].
3. Mixing Server and Client State
Mistake: Storing UI toggles, form drafts, or animation states inside query cache.
Impact: Cache pollution, incorrect invalidation, and SSR hydration mismatches.
Fix: Server state belongs in React Query. Client state belongs in useState, useReducer, or Zustand/Jotai. Never mutate query data with UI-only flags.
4. Ignoring Retry and Error Boundaries
Mistake: Assuming retry: false or relying solely on component-level error handling.
Impact: Transient network failures cause hard crashes or silent data loss.
Fix: Configure retry based on idempotency. GET requests: 2-3 retries. POST/PUT: 0 retries (unless backend supports idempotency keys). Wrap query consumers in ErrorBoundary components for graceful degradation.
5. Manual Cache Updates Without Invalidation
Mistake: Using setQueryData to patch cache but forgetting to trigger invalidateQueries for dependent lists.
Impact: Detail views update, but list views show stale data. UI inconsistency.
Fix: Always pair setQueryData with targeted invalidation. Prefer invalidateQueries over direct mutation unless implementing strict optimistic updates with rollback.
6. Using Queries for Static or One-Time Data
Mistake: Fetching configuration files, static assets, or server-rendered initial data through useQuery.
Impact: Unnecessary cache entries, hydration mismatches in SSR, and wasted memory.
Fix: Inject static data via props or context. Use useQuery only for data that requires caching, background refetching, or invalidation.
7. Forgetting gcTime Configuration
Mistake: Leaving default gcTime (5 minutes) in memory-constrained environments or long-running SPAs.
Impact: Memory bloat, especially with high-cardinality query keys (e.g., search results, infinite lists).
Fix: Adjust gcTime per route or feature. Use queryClient.removeQueries() for explicit cleanup. Monitor memory via React Query Devtools.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Static configuration/metadata | Inject via props/context | No caching or invalidation needed; avoids cache pollution | Zero runtime overhead |
| Real-time dashboard/data | useQuery with staleTime: 10s, refetchInterval: 10s | Balances freshness with network efficiency | Moderate bandwidth, high perceived responsiveness |
| Form submissions/state changes | useMutation with optimistic updates + invalidateQueries | Eliminates loading spinners while preserving data integrity | Slight complexity increase, major UX improvement |
| Pagination/infinite scroll | useQuery with keepPreviousData: true, cursor-based keys | Prevents UI flicker, supports back/forward navigation | Minimal bundle impact, improved scroll performance |
| SSR hydration | hydrate/dehydrate with QueryClient | Prevents double-fetching, matches server-rendered state | Requires build-time coordination, eliminates hydration mismatches |
Configuration Template
// src/config/queryClient.ts
import { QueryClient } from '@tanstack/react-query';
export const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60,
gcTime: 1000 * 60 * 10,
retry: (failureCount, error) => {
if (error instanceof TypeError) return failureCount < 2; // network errors
return false; // server errors fail fast
},
refetchOnWindowFocus: false,
refetchOnMount: 'always',
},
mutations: {
retry: 0,
onError: (error) => {
console.error('[Query] Mutation failed:', error);
// Integrate with error tracking (Sentry, LogRocket, etc.)
},
},
},
});
Quick Start Guide
- Install dependencies:
npm install @tanstack/react-query @tanstack/react-query-devtools
- Wrap your application root with
QueryClientProvider using the template above
- Replace
useEffect + useState fetching blocks with useQuery({ queryKey: [...], queryFn: ... })
- Add
useMutation for data modifications, implementing onMutate/onError/onSettled for cache sync
- Open React Query Devtools (default:
Ctrl/Cmd + Q) to verify cache topology, query status, and invalidation flow