# React Query Best Practices
**Version 1.0.0** | **React Query v4**
Based on TkDodo's Blog Series
January 2026
> **Important:** This guide targets **React Query v4**. Some patterns may differ in v5.
> **Note:**
> This document is mainly for agents and LLMs to follow when maintaining,
> generating, or refactoring React Query codebases. Humans may also find
> it useful, but guidance here is optimized for automation and consistency
> by AI-assisted workflows.
---
## Abstract
Comprehensive guide for React Query (TanStack Query) based on TkDodo's authoritative blog series. Contains 24 rules across 7 categories, prioritized by impact from critical (query keys, mutations) to incremental (testing, troubleshooting). Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics to guide automated refactoring and code generation.
---
## Table of Contents
1. [Query Keys & Patterns](#1-query-keys--patterns) — **CRITICAL**
- 1.1 [Query Keys as Dependencies](#11-query-keys-as-dependencies)
- 1.2 [Query Key Factory Pattern](#12-query-key-factory-pattern)
- 1.3 [Select Option for Transformations](#13-select-option-for-transformations)
- 1.4 [Status Check Order](#14-status-check-order)
- 1.5 [Tracked Properties](#15-tracked-properties)
- 1.6 [Placeholder vs Initial Data](#16-placeholder-vs-initial-data)
- 1.7 [Dependent Queries with Enabled](#17-dependent-queries-with-enabled)
2. [Mutations & Updates](#2-mutations--updates) — **CRITICAL**
- 2.1 [Prefer mutate() Over mutateAsync()](#21-prefer-mutate-over-mutateasync)
- 2.2 [Query Invalidation](#22-query-invalidation)
- 2.3 [Direct Cache Updates](#23-direct-cache-updates)
- 2.4 [Optimistic Updates](#24-optimistic-updates)
- 2.5 [Callback Separation Pattern](#25-callback-separation-pattern)
3. [Caching Strategy](#3-caching-strategy) — **HIGH**
- 3.1 [Setting staleTime](#31-setting-staletime)
- 3.2 [Refetch Triggers](#32-refetch-triggers)
4. [WebSocket Integration](#4-websocket-integration) — **HIGH**
- 4.1 [Event-Based Invalidation](#41-event-based-invalidation)
- 4.2 [Infinite staleTime for WS Data](#42-infinite-staletime-for-ws-data)
- 4.3 [Reconnection Handling](#43-reconnection-handling)
5. [TypeScript Integration](#5-typescript-integration) — **MEDIUM**
- 5.1 [Type Inference Strategy](#51-type-inference-strategy)
- 5.2 [Runtime Validation with Zod](#52-runtime-validation-with-zod)
6. [Testing Patterns](#6-testing-patterns) — **MEDIUM**
- 6.1 [Fresh QueryClient Per Test](#61-fresh-queryclient-per-test)
- 6.2 [MSW for Network Mocking](#62-msw-for-network-mocking)
7. [Common Pitfalls](#7-common-pitfalls) — **MEDIUM**
- 7.1 [Copying Query Data to State](#71-copying-query-data-to-state)
- 7.2 [Missing Query Key Dependencies](#72-missing-query-key-dependencies)
- 7.3 [Fetch Not Rejecting on Errors](#73-fetch-not-rejecting-on-errors)
8. [Migration to v5](#8-migration-to-v5) — **HIGH**
- 8.1 [cacheTime Renamed to gcTime](#81-cachetime-renamed-to-gctime)
- 8.2 [Query Callbacks Removed](#82-query-callbacks-removed)
- 8.3 [New Suspense Hooks](#83-new-suspense-hooks)
---
## 1. Query Keys & Patterns
**Impact: CRITICAL**
Query keys are the foundation of React Query. Getting them right ensures proper caching, automatic refetching, and predictable behavior.
### 1.1 Query Keys as Dependencies
**Impact: CRITICAL (prevents cache collisions and stale closures)**
Include ALL variables used in queryFn as part of the queryKey. Query keys work like useEffect dependencies.
**Incorrect: filters not in queryKey**
```typescript
const [filters, setFilters] = useState({ status: 'active' });
useQuery({
queryKey: ['todos'], // Missing filters!
queryFn: () => fetchTodos(filters),
});
```
**Correct: all dependencies included**
```typescript
useQuery({
queryKey: ['todos', filters], // Key changes when filters change
queryFn: () => fetchTodos(filters),
});
```
Key changes automatically trigger refetches. This is the declarative pattern React Query is built for.
### 1.2 Query Key Factory Pattern
**Impact: HIGH (enables flexible invalidation)**
Use factory functions to generate consistent query keys. Structure: generic to specific.
**Incorrect: inconsistent key strings**
```typescript
// Scattered key definitions
useQuery({ queryKey: ['todos'] });
useQuery({ queryKey: ['todo', id] });
queryClient.invalidateQueries({ queryKey: ['todos'] }); // Might miss some
```
**Correct: centralized key factory**
```typescript
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,
};
// Usage
useQuery({
queryKey: todoKeys.detail(todoId),
queryFn: () => fetchTodo(todoId),
});
// Flexible invalidation
queryClient.invalidateQueries({ queryKey: todoKeys.all }); // All todos
queryClient.invalidateQueries({ queryKey: todoKeys.lists() }); // All lists
queryClient.invalidateQueries({ queryKey: todoKeys.detail(5) }); // Specific todo
```
### 1.3 Select Option for Transformations
**Impact: MEDIUM (enables partial subscriptions)**
Use the `select` option for data transformations. It only runs when data exists and enables render optimizations.
**Incorrect: transform in queryFn or component**
```typescript
// Transform runs on every fetch
useQuery({
queryKey: ['todos'],
queryFn: async () => {
const todos = await fetchTodos();
return todos.filter(todo => !todo.done); // Runs every time
},
});
// Or transform in component (no memoization)
const { data } = useQuery({...});
const activeTodos = data?.filter(todo => !todo.done);
```
**Correct: use select option**
```typescript
// Only runs when data exists, enables partial subscriptions
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: (data) => data.filter(todo => !todo.done),
});
// Component only re-renders when count changes
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: (data) => data.length,
});
```
For expensive transforms, stabilize with useCallback:
```typescript
const selectActiveTodos = useCallback(
(data: Todo[]) => data.filter(todo => !todo.done),
[]
);
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: selectActiveTodos,
});
```
### 1.4 Status Check Order
**Impact: MEDIUM (proper stale-while-revalidate UX)**
Check data availability FIRST, following the stale-while-revalidate philosophy.
**Incorrect: error blocks stale data display**
```typescript
function TodoList() {
const { data, error, isPending } = useQuery({...});
if (isPending) return ;
if (error) return ; // Hides stale data on background refetch error
return
;
}
```
**Correct: show stale data during background errors**
```typescript
function TodoList() {
const { data, error, isPending } = useQuery({...});
// Data first - show stale data during background refetch errors
if (data) {
return
;
}
// Then error (only when no data available)
if (error) {
return ;
}
// Finally loading (initial load only)
if (isPending) {
return ;
}
}
```
### 1.5 Tracked Properties
**Impact: LOW (reduces unnecessary re-renders)**
React Query tracks which properties you access during render. Only destructure what you need.
**Incorrect: rest spread tracks all fields**
```typescript
// Tracks ALL fields, defeats optimization
const { data, ...rest } = useQuery({...});
```
**Correct: explicit destructuring**
```typescript
// Only tracks data and isError
const { data, isError } = useQuery({...});
// Component won't re-render when isFetching changes
```
### 1.6 Placeholder vs Initial Data
**Impact: MEDIUM (affects caching behavior)**
Know when to use `initialData` (persists to cache) vs `placeholderData` (temporary).
| Aspect | initialData | placeholderData |
|--------|-------------|-----------------|
| Persists to cache | Yes | No |
| Respects staleTime | Yes | No (always refetches) |
| Scope | Cache-level (shared) | Observer-level (per component) |
**initialData: pre-fill from another query's cache**
```typescript
useQuery({
queryKey: ['todo', todoId],
queryFn: () => fetchTodo(todoId),
initialData: () => {
return queryClient
.getQueryData(['todos'])
?.find(todo => todo.id === todoId);
},
initialDataUpdatedAt: () => {
return queryClient.getQueryState(['todos'])?.dataUpdatedAt;
},
});
```
**placeholderData: temporary data during fetch**
```typescript
useQuery({
queryKey: ['todo', todoId],
queryFn: () => fetchTodo(todoId),
placeholderData: { id: todoId, title: 'Loading...', done: false },
});
// For smooth pagination transitions
useQuery({
queryKey: ['todos', page],
queryFn: () => fetchTodos(page),
placeholderData: keepPreviousData,
});
```
### 1.7 Dependent Queries with Enabled
**Impact: HIGH (prevents unnecessary fetches)**
Use the `enabled` option to control when queries run.
**Incorrect: query runs with undefined params**
```typescript
const { data: user } = useQuery({...});
// Runs immediately with undefined userId!
const { data: projects } = useQuery({
queryKey: ['projects', user?.id],
queryFn: () => fetchProjects(user?.id),
});
```
**Correct: enabled option controls execution**
```typescript
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
const { data: projects } = useQuery({
queryKey: ['projects', user?.id],
queryFn: () => fetchProjects(user!.id),
enabled: !!user?.id, // Only fetch when user is loaded
});
```
**With skipToken (v5.25+):**
```typescript
import { skipToken } from '@tanstack/react-query';
useQuery({
queryKey: ['user', userId],
queryFn: userId ? () => fetchUser(userId) : skipToken,
});
```
---
## 2. Mutations & Updates
**Impact: CRITICAL**
Mutations modify server state. Proper patterns ensure data consistency and good UX.
### 2.1 Prefer mutate() Over mutateAsync()
**Impact: HIGH (better error handling)**
Use `mutate()` with callbacks for most cases. Only use `mutateAsync()` for sequential operations.
**Incorrect: unhandled promise rejection**
```typescript
const { mutateAsync } = useMutation({...});
// Easy to forget try/catch
const handleSave = () => {
mutateAsync(data); // Unhandled rejection if error!
};
```
**Correct: mutate with callbacks**
```typescript
const { mutate } = useMutation({
mutationFn: updateTodo,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
mutate(todoData, {
onSuccess: () => toast.success('Saved!'),
onError: (error) => toast.error(error.message),
});
```
**Use mutateAsync only for sequential mutations:**
```typescript
async function handleComplexSave() {
try {
const user = await createUserMutation.mutateAsync(userData);
await createProfileMutation.mutateAsync({
userId: user.id,
...profileData,
});
toast.success('User and profile created!');
} catch (error) {
toast.error('Failed to create user');
}
}
```
### 2.2 Query Invalidation
**Impact: CRITICAL (ensures data consistency)**
Invalidate related queries after mutations to trigger refetches.
**Incorrect: manual refetch or no sync**
```typescript
const { mutate } = useMutation({
mutationFn: updateTodo,
onSuccess: () => {
// Manual refetch - doesn't work well with multiple components
refetch();
},
});
```
**Correct: invalidate queries**
```typescript
const mutation = useMutation({
mutationFn: updateTodo,
onSuccess: () => {
// Marks queries as stale, triggers refetch for active ones
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
```
**Await invalidation to keep button disabled:**
```typescript
const mutation = useMutation({
mutationFn: updateTodo,
onSuccess: async () => {
// Keep isPending true until refetch completes
await queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
```
### 2.3 Direct Cache Updates
**Impact: MEDIUM (instant UI feedback)**
Update cache directly when mutation returns updated data.
```typescript
const mutation = useMutation({
mutationFn: updateTodo,
onSuccess: (updatedTodo) => {
// Update specific item in list
queryClient.setQueryData(['todos'], (old: Todo[]) =>
old.map(todo =>
todo.id === updatedTodo.id ? updatedTodo : todo
)
);
// Also update detail cache
queryClient.setQueryData(
['todos', updatedTodo.id],
updatedTodo
);
},
});
```
### 2.4 Optimistic Updates
**Impact: HIGH (instant perceived performance)**
Show success immediately, rollback on failure.
```typescript
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['todos', newTodo.id] });
// Snapshot previous value
const previousTodo = queryClient.getQueryData(['todos', newTodo.id]);
// Optimistically update
queryClient.setQueryData(['todos', newTodo.id], newTodo);
// Return context for rollback
return { previousTodo };
},
onError: (err, newTodo, context) => {
// Rollback on error
if (context?.previousTodo) {
queryClient.setQueryData(
['todos', newTodo.id],
context.previousTodo
);
}
},
onSettled: () => {
// Always refetch to ensure consistency
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
```
**Good candidates for optimistic updates:**
- Toggle actions (like/unlike, done/undone)
- Simple field updates
- High-confidence mutations
**Avoid for:**
- Complex operations that might fail
- Actions requiring server-side validation
### 2.5 Callback Separation Pattern
**Impact: MEDIUM (separation of concerns)**
Place query logic in hook, UI effects in component.
**Custom hook - query-related logic:**
```typescript
export function useUpdateTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateTodo,
onSuccess: (data) => {
// Query cache updates here
queryClient.setQueryData(['todos', data.id], data);
queryClient.invalidateQueries({ queryKey: ['todos', 'list'] });
},
onError: (error) => {
// Logging here
console.error('Update failed:', error);
},
});
}
```
**Component - UI effects:**
```typescript
function TodoEditor() {
const { mutate } = useUpdateTodo();
const handleSave = () => {
mutate(todoData, {
onSuccess: () => {
toast.success('Saved!');
closeModal();
},
onError: () => {
toast.error('Save failed');
},
});
};
}
```
---
## 3. Caching Strategy
**Impact: HIGH**
Proper caching configuration balances freshness with performance.
### 3.1 Setting staleTime
**Impact: HIGH (reduces unnecessary refetches)**
Set appropriate `staleTime` based on how often your data changes.
**Recommended defaults:**
```typescript
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 2, // 2 minutes
gcTime: 1000 * 60 * 5, // 5 minutes (formerly cacheTime)
retry: 3,
},
},
});
```
**Per-query overrides:**
```typescript
// Frequently changing data
useQuery({
queryKey: ['notifications'],
queryFn: fetchNotifications,
staleTime: 1000 * 30, // 30 seconds
});
// Rarely changing data
useQuery({
queryKey: ['config'],
queryFn: fetchConfig,
staleTime: 1000 * 60 * 30, // 30 minutes
});
// WebSocket-managed data
useQuery({
queryKey: ['automations', id],
queryFn: () => fetchAutomation(id),
staleTime: Infinity, // Manual invalidation via WebSocket
});
```
### 3.2 Refetch Triggers
**Impact: HIGH (data freshness)**
Keep refetch triggers enabled in production. They're features, not bugs.
**Incorrect: over-disabling**
```typescript
useQuery({
queryKey: ['user'],
queryFn: fetchUser,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
staleTime: Infinity,
});
// Data never updates automatically!
```
**Correct: appropriate configuration**
```typescript
// Most queries: keep defaults
useQuery({
queryKey: ['user'],
queryFn: fetchUser,
staleTime: 1000 * 60 * 5, // 5 minutes
// refetchOnWindowFocus: true (default)
// refetchOnMount: true (default)
});
// Only disable for truly static or WebSocket-managed data
useQuery({
queryKey: ['automations', id],
queryFn: () => fetchAutomation(id),
staleTime: Infinity,
refetchOnWindowFocus: false, // WebSocket handles this
});
```
---
## 4. WebSocket Integration
**Impact: HIGH**
Real-time data synchronization patterns for WebSocket-driven updates.
### 4.1 Event-Based Invalidation
**Impact: HIGH (simple, predictable)**
Push lightweight events from backend, not full data objects.
```typescript
// Backend sends events like:
// { "entity": ["automations", "list"] }
// { "entity": ["automations", "detail"], "id": "abc123" }
function useWebSocketSync() {
const queryClient = useQueryClient();
useEffect(() => {
const socket = connectWebSocket();
socket.on('invalidate', (event) => {
queryClient.invalidateQueries({
queryKey: event.entity,
});
});
return () => socket.disconnect();
}, [queryClient]);
}
```
**Example pattern (entity-specific WebSocket handler):**
```typescript
const handleMessage = useCallback((message: WebSocketMessage) => {
switch (message.type) {
case 'AUTOMATION_UPDATED':
queryClient.invalidateQueries({
queryKey: ['automations', message.automationId],
});
break;
case 'AUTOMATION_STATUS_CHANGED':
// Direct cache update for frequent status changes
queryClient.setQueryData(
['automations', message.automationId],
(old) => old ? { ...old, status: message.status } : old
);
break;
case 'AUTOMATION_LIST_CHANGED':
queryClient.invalidateQueries({
queryKey: ['automations', 'list'],
});
break;
}
}, [queryClient]);
```
### 4.2 Infinite staleTime for WS Data
**Impact: HIGH (prevents redundant fetches)**
When WebSocket handles updates, disable automatic refetching.
```typescript
// Queries updated via WebSocket
useQuery({
queryKey: ['automations', automationId],
queryFn: () => fetchAutomation(automationId),
staleTime: Infinity, // Manual invalidation via WebSocket
refetchOnWindowFocus: false,
});
```
### 4.3 Reconnection Handling
**Impact: MEDIUM (ensures consistency after disconnect)**
Invalidate stale queries when WebSocket reconnects.
```typescript
function useWebSocketWithReconnect() {
const queryClient = useQueryClient();
const [isConnected, setIsConnected] = useState(false);
useEffect(() => {
const socket = connectWebSocket();
socket.on('connect', () => {
setIsConnected(true);
// Refresh stale data after reconnection
queryClient.invalidateQueries({
predicate: (query) => query.state.isInvalidated,
});
});
socket.on('disconnect', () => {
setIsConnected(false);
});
return () => socket.disconnect();
}, [queryClient]);
return isConnected;
}
```
**Fallback to polling when disconnected:**
```typescript
const { data } = useQuery({
queryKey: ['automations', id],
queryFn: () => fetchAutomation(id),
staleTime: isWebSocketConnected ? Infinity : 0,
refetchInterval: isWebSocketConnected ? false : 5000,
});
```
---
## 5. TypeScript Integration
**Impact: MEDIUM**
Let TypeScript infer types rather than explicitly specifying generics.
### 5.1 Type Inference Strategy
**Impact: MEDIUM (cleaner code, fewer errors)**
Type the queryFn, not the hook.
**Incorrect: explicit generics**
```typescript
// Unnecessary and error-prone
const { data } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
```
**Correct: type the function**
```typescript
// Type flows automatically
async function fetchTodos(): Promise {
const response = await fetch('/api/todos');
return response.json();
}
const { data } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
// data is Todo[] | undefined
```
**With select, inference still works:**
```typescript
const { data } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: (todos) => todos.length,
});
// data is number | undefined
```
### 5.2 Runtime Validation with Zod
**Impact: HIGH (catches API contract violations)**
Replace type assertions with runtime validation.
```typescript
import { z } from 'zod';
const todoSchema = z.object({
id: z.number(),
title: z.string(),
done: z.boolean(),
createdAt: z.string().transform(s => new Date(s)),
});
const todosSchema = z.array(todoSchema);
type Todo = z.infer;
async function fetchTodos(): Promise {
const response = await fetch('/api/todos');
const data = await response.json();
return todosSchema.parse(data); // Runtime validation
}
```
**Benefits:**
- Catches data shape mismatches at runtime
- Failed validation triggers error state
- Single source of truth for types
---
## 6. Testing Patterns
**Impact: MEDIUM**
Proper test setup ensures reliable, isolated tests.
### 6.1 Fresh QueryClient Per Test
**Impact: CRITICAL (test isolation)**
Never share QueryClient between tests.
**Incorrect: shared client**
```typescript
const queryClient = new QueryClient(); // Shared!
describe('TodoList', () => {
it('test 1', () => {
render(, { wrapper: ... });
});
it('test 2', () => {
// Cache pollution from test 1!
render(, { wrapper: ... });
});
});
```
**Correct: fresh client per test**
```typescript
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
},
mutations: {
retry: false,
},
},
logger: {
log: console.log,
warn: console.warn,
error: () => {}, // Silence error logs
},
});
}
function createWrapper() {
const queryClient = createTestQueryClient();
return function Wrapper({ children }: { children: React.ReactNode }) {
return (
{children}
);
};
}
describe('TodoList', () => {
it('test 1', () => {
render(, { wrapper: createWrapper() });
});
it('test 2', () => {
render(, { wrapper: createWrapper() }); // Fresh!
});
});
```
### 6.2 MSW for Network Mocking
**Impact: HIGH (single source of truth)**
Use Mock Service Worker instead of mocking useQuery directly.
```typescript
// src/mocks/handlers.ts
import { rest } from 'msw';
export const handlers = [
rest.get('/api/todos', (req, res, ctx) => {
return res(
ctx.json([
{ id: 1, title: 'Todo 1', done: false },
{ id: 2, title: 'Todo 2', done: true },
])
);
}),
];
// src/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// src/setupTests.ts
import { server } from './mocks/server';
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
```
**Testing error states:**
```typescript
it('handles error state', async () => {
server.use(
rest.get('/api/todos', (req, res, ctx) => {
return res(ctx.status(500));
})
);
const { result } = renderHook(() => useTodos(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
});
```
---
## 7. Common Pitfalls
**Impact: MEDIUM**
Avoid these common mistakes that break React Query's guarantees.
### 7.1 Copying Query Data to State
**Impact: HIGH (breaks single source of truth)**
Never copy query results to local state.
**Incorrect: duplicated state**
```typescript
const { data } = useQuery({ queryKey: ['user'], queryFn: fetchUser });
const [user, setUser] = useState(data);
useEffect(() => {
if (data) setUser(data);
}, [data]);
// Breaks background updates, creates sync bugs
```
**Correct: use query data directly**
```typescript
const { data: user } = useQuery({
queryKey: ['user'],
queryFn: fetchUser,
});
// For derived state, use select
const { data: userName } = useQuery({
queryKey: ['user'],
queryFn: fetchUser,
select: (user) => user.name,
});
```
### 7.2 Missing Query Key Dependencies
**Impact: CRITICAL (causes cache collisions)**
Always include all queryFn parameters in the query key.
**Incorrect: filters not in key**
```typescript
const [filters, setFilters] = useState({ status: 'active' });
useQuery({
queryKey: ['todos'], // Missing filters!
queryFn: () => fetchTodos(filters),
});
// Cache collision between different filter states
```
**Correct: include all dependencies**
```typescript
useQuery({
queryKey: ['todos', filters],
queryFn: () => fetchTodos(filters),
});
```
### 7.3 Fetch Not Rejecting on Errors
**Impact: HIGH (silent failures)**
The fetch API doesn't reject on HTTP errors.
**Incorrect: 404 treated as success**
```typescript
const { data, error } = useQuery({
queryKey: ['user'],
queryFn: () => fetch('/api/user').then(res => res.json()),
});
// error is always null even on 404!
```
**Correct: check response.ok**
```typescript
const { data, error } = useQuery({
queryKey: ['user'],
queryFn: async () => {
const res = await fetch('/api/user');
if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`);
}
return res.json();
},
});
```
**Or use axios (rejects automatically):**
```typescript
const { data, error } = useQuery({
queryKey: ['user'],
queryFn: () => axios.get('/api/user').then(res => res.data),
});
```
---
## 8. Migration to v5
**Impact: HIGH**
When upgrading from React Query v4 to v5, these are the key breaking changes to address.
### 8.1 cacheTime Renamed to gcTime
**Impact: HIGH (breaking change, find-replace required)**
In v5, `cacheTime` was renamed to `gcTime` to better reflect its purpose: it controls when unused/inactive cache entries are garbage collected.
**v4 (before):**
```typescript
const queryClient = new QueryClient({
defaultOptions: {
queries: {
cacheTime: 1000 * 60 * 5, // 5 minutes
},
},
});
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
cacheTime: 1000 * 60 * 10, // 10 minutes
});
```
**v5 (after):**
```typescript
const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 60 * 5, // 5 minutes
},
},
});
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
gcTime: 1000 * 60 * 10, // 10 minutes
});
```
**Migration:** Find and replace all occurrences of `cacheTime` with `gcTime`.
### 8.2 Query Callbacks Removed
**Impact: HIGH (breaking change, refactor required)**
In v5, the `onSuccess`, `onError`, and `onSettled` callbacks were removed from `useQuery`. Use `useEffect` or handle in the component instead.
**Why removed:** These callbacks had subtle timing issues and didn't play well with React's concurrent features.
**v4 (before):**
```typescript
useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
onSuccess: (data) => {
console.log('User loaded:', data);
analytics.track('user_loaded');
},
onError: (error) => {
toast.error(error.message);
},
});
```
**v5 (after):**
```typescript
const { data, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
useEffect(() => {
if (data) {
console.log('User loaded:', data);
analytics.track('user_loaded');
}
}, [data]);
useEffect(() => {
if (error) {
toast.error(error.message);
}
}, [error]);
```
**Note:** Mutation callbacks (`onSuccess`, `onError`, `onSettled`) are still available on `useMutation`.
### 8.3 New Suspense Hooks
**Impact: HIGH (new API for suspense mode)**
In v5, suspense mode uses dedicated hooks instead of the `suspense` option. This provides better TypeScript inference since data is guaranteed to be defined.
**v4 (before):**
```typescript
const { data } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
suspense: true,
});
// data is TData | undefined (TypeScript doesn't know suspense guarantees data)
```
**v5 (after):**
```typescript
import { useSuspenseQuery } from '@tanstack/react-query';
const { data } = useSuspenseQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
// data is TData (guaranteed by suspense, TypeScript knows this)
```
**Available suspense hooks in v5:**
- `useSuspenseQuery` - Single query with suspense
- `useSuspenseInfiniteQuery` - Infinite query with suspense
- `useSuspenseQueries` - Multiple queries with suspense
---
## Best Practices Summary
### DO
- Include all queryFn parameters in queryKey
- Use `mutate()` with callbacks for most mutations
- Set appropriate `staleTime` for your domain
- Keep `refetchOnWindowFocus` enabled in production
- Use `select` for derived/computed data
- Create fresh `QueryClient` per test
- Use Zod for runtime API validation
### DON'T
- Copy query results to local state
- Use `useEffect` to sync React Query state elsewhere
- Disable refetch triggers without good reason
- Use same keys for `useQuery` and `useInfiniteQuery`
- Forget to handle loading/error states
- Add explicit generics to useQuery/useMutation