TanStack Query (React Query) Best Practices
Comprehensive guide for TanStack Query based on TkDodo's authoritative blog posts. TkDodo (Dominik Dorfmeister) is a core maintainer of TanStack Query. Contains patterns across 17 categories covering queries, mutations, caching, TypeScript, testing, and advanced patterns.
When to Apply
Reference these guidelines when:
- Writing new
useQuery,useMutation, oruseInfiniteQueryhooks - Structuring query keys for a feature or application
- Implementing optimistic updates or cache synchronization
- Debugging unexpected refetches or stale data issues
- Integrating React Query with TypeScript
- Testing components that use React Query
- Deciding between React Query and other solutions
Rule Categories by Priority
| Priority | Category | Impact | Key Concept |
|---|---|---|---|
| 1 | Mental Model | CRITICAL | React Query is an async state manager, not a fetching library |
| 2 | Query Keys | CRITICAL | Keys are dependencies - include all variables that affect data |
| 3 | Status Handling | HIGH | Check data first, then error, then loading |
| 4 | Mutations | HIGH | Invalidation vs direct cache updates, optimistic patterns |
| 5 | TypeScript | HIGH | Prefer inference over explicit generics |
| 6 | Cache Management | MEDIUM-HIGH | placeholderData vs initialData, seeding strategies |
| 7 | Error Handling | MEDIUM-HIGH | Global callbacks, Error Boundaries, granular throwOnError |
| 8 | Render Optimization | MEDIUM | Tracked queries, structural sharing, select |
| 9 | Testing | MEDIUM | Fresh QueryClient per test, disable retries, use MSW |
| 10 | Advanced Patterns | LOW-MEDIUM | WebSockets, Router integration, Infinite queries |
Quick Reference
1. Core Mental Model (CRITICAL)
mental-not-fetching-lib- React Query is an async state manager, not a fetching librarymental-server-vs-client- Server state is borrowed; client state is ownedmental-stale-time- staleTime controls when data becomes eligible for background refetchmental-gc-time- gcTime controls when inactive cache entries are garbage collectedmental-no-sync-to-state- Never copy query data to local state with useState/useEffect
2. Query Keys (CRITICAL)
keys-array-format- Always use array keys:['todos']not'todos'keys-generic-to-specific- Structure from most generic to most specifickeys-include-dependencies- Include all variables that determine what data to fetchkeys-factory-pattern- Use query key factories for consistency and type safetykeys-exact-match- Keys must match exactly:['item', '1']!==['item', 1]
3. Status Handling (HIGH)
status-data-first- Check data availability before error statestatus-avoid-status-first- Don't hide cached data during background refetch errorsstatus-fetch-status- Use fetchStatus for paused/fetching states separate from data status
4. Mutations (HIGH)
mutation-invalidation- Prefer invalidation over direct cache updates for safetymutation-return-promise- Return invalidation promises to keep mutations in loading statemutation-prefer-mutate- Prefer mutate() over mutateAsync() to avoid manual error handlingmutation-single-arg- Mutations accept one variable - use objects for multiple valuesmutation-callback-lifecycle- Query logic in useMutation callbacks; UI actions in mutate() callbacksmutation-optimistic-when- Use optimistic updates for high-confidence toggles, not navigation
5. TypeScript (HIGH)
ts-prefer-inference- Let TypeScript infer from well-typed queryFn return typests-no-destructure- Keep query object intact for proper type narrowingts-query-options- Use queryOptions() helper for type-safe reusable query definitionsts-factories-with-options- Combine key factories with queryOptions for full type safety
6. Cache Management (MEDIUM-HIGH)
cache-placeholder-vs-initial- placeholderData for fake data; initialData for real cached datacache-initial-data-updated-at- Specify initialDataUpdatedAt for proper stale time calculationcache-seed-pull- Pull from list cache to detail cache with initialDatacache-seed-push- Push to detail caches after fetching lists with setQueryDatacache-prefetch- Use prefetchQuery in loaders or on hover for instant navigation
7. Error Handling (MEDIUM-HIGH)
error-boundaries- Use throwOnError with Error Boundaries for critical errorserror-granular-throw- Use function throwOnError for selective error boundary routingerror-global-callbacks- Use QueryCache onError for background refetch toastserror-fetch-api- Check response.ok with fetch API - it doesn't reject on 4xx/5xxerror-rethrow- Always re-throw errors after logging in catch blocks
8. Render Optimization (MEDIUM)
render-tracked-queries- Use tracked queries (default v4+) for automatic optimizationrender-select- Use select option for computed data and partial subscriptionsrender-structural-sharing- Leverage structural sharing; disable for large datasetsrender-no-spread- Avoid spreading query result to preserve tracked query benefits
9. Testing (MEDIUM)
test-fresh-client- Create new QueryClient for each test for isolationtest-disable-retries- Set retry: false in test configurationtest-use-msw- Use Mock Service Worker for network mockingtest-wait-for- Use waitFor with expect() for async assertions
10. Advanced Patterns (LOW-MEDIUM)
advanced-websocket-invalidate- Use WebSocket events to invalidateQueriesadvanced-ws-stale-time- Set high staleTime when WebSockets handle updatesadvanced-router-loader- Combine router loaders with React Query cachingadvanced-infinite-page-param- Use getNextPageParam returning null for end of listadvanced-context-provider- Use Context to guarantee data availability without undefinedadvanced-suspense-query- Use useSuspenseQuery for guaranteed data in components
Detailed Rules
mental-not-fetching-lib
React Query is agnostic about how you fetch. It only needs a Promise that resolves or rejects. Handle baseURLs, headers, and GraphQL in your data layer.
mental-server-vs-client
Server state is a snapshot you don't own - other users can modify it. Client state (dark mode, UI toggles) is synchronous and yours. Treating them the same leads to problems.
keys-factory-pattern
const todoKeys = {
all: ['todos'] as const,
lists: () => [...todoKeys.all, 'list'] as const,
list: (filters: Filters) => [...todoKeys.lists(), { filters }] as const,
details: () => [...todoKeys.all, 'detail'] as const,
detail: (id: number) => [...todoKeys.details(), id] as const,
}
status-data-first
// Correct: prioritize cached data
if (query.data) return <Content data={query.data} />
if (query.error) return <Error error={query.error} />
return <Loading />
mutation-return-promise
// Correct: mutation stays loading during invalidation
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['todos'] })
// Wrong: mutation completes before invalidation
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['todos'] }) }
ts-query-options
const todosQuery = queryOptions({
queryKey: ['todos'],
queryFn: fetchTodos,
})
// Reusable and type-safe
useQuery(todosQuery)
queryClient.prefetchQuery(todosQuery)
const data = queryClient.getQueryData(todosQuery.queryKey) // Typed!
cache-placeholder-vs-initial
- placeholderData: Observer-level, never cached, always refetches, use for "fake" data
- initialData: Cache-level, persisted, respects staleTime, use for "real" data from other cache entries
error-global-callbacks
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error, query) => {
// Only toast for background refetch failures (has existing data)
if (query.state.data !== undefined) {
toast.error(`Background update failed: ${error.message}`)
}
},
}),
})
advanced-router-loader
// Loader
export const loader = (queryClient: QueryClient) =>
async ({ params }: LoaderFunctionArgs) => {
const query = todoQuery(params.id!)
return queryClient.getQueryData(query.queryKey) ??
await queryClient.fetchQuery(query)
}
// Component - instant data from loader, background updates from React Query
const { data } = useQuery({ ...todoQuery(id), initialData: useLoaderData() })
Anti-Patterns to Avoid
Don't sync to local state
// Anti-pattern
const { data } = useQuery({...})
const [localData, setLocalData] = useState(data)
useEffect(() => { setLocalData(data) }, [data])
// Correct: use data directly, or use select for transformations
const { data } = useQuery({...})
Don't use QueryCache as state manager
setQueryData is for optimistic updates and mutation responses only. Background refetches will override manually-set data.
Don't create unstable QueryClient
// Wrong: recreates on every render
function App() {
const queryClient = new QueryClient()
return <QueryClientProvider client={queryClient}>...</QueryClientProvider>
}
// Correct: stable reference
const queryClient = new QueryClient()
function App() {
return <QueryClientProvider client={queryClient}>...</QueryClientProvider>
}
When NOT to Use React Query
- React Server Components: Use framework-native data fetching
- Next.js/Remix with simple needs: Built-in solutions may suffice
- GraphQL with normalized cache needs: Consider Apollo Client or urql
- No background refetch requirements: Static SSR may be enough
Resources
- Official docs: https://tanstack.com/query
- TkDodo's blog: https://tkdodo.eu/blog (authoritative source for best practices)
- Key articles: Practical React Query, Thinking in React Query, Effective React Query Keys
For complete explanations and code examples, see references/react-query-context-source.md