- name
- tanstack-query
- description
- Comprehensive TanStack Query v5 patterns for async state management. Covers breaking changes, query key factories, data transformation, mutations, optimistic updates, authentication, testing with MSW, and anti-patterns. Use for all server state management, data fetching, and cache invalidation tasks.
# TanStack Query v5 - Complete Guide
**TanStack Query v5** (October 2023) is the async state manager for this project. It requires React 18+, features first-class Suspense support, improved TypeScript inference, and a 20% smaller bundle. This section covers production-ready patterns based on official documentation and community best practices.
### Breaking Changes in v5
**Key updates you need to know:**
1. **Single Object Signature**: All hooks now accept one configuration object:
```typescript
// ✅ v5 - single object
useQuery({ queryKey, queryFn, ...options })
// ❌ v4 - multiple overloads (deprecated)
useQuery(queryKey, queryFn, options)
```
2. **Renamed Options**:
- `cacheTime` → `gcTime` (garbage collection time)
- `keepPreviousData` → `placeholderData: keepPreviousData`
- `isLoading` now means `isPending && isFetching`
3. **Callbacks Removed from useQuery**:
- `onSuccess`, `onError`, `onSettled` removed from `useQuery`
- Use global QueryCache callbacks instead
- Prevents duplicate executions
4. **Infinite Queries Require initialPageParam**:
- No default value provided
- Must explicitly set `initialPageParam` (e.g., `0` or `null`)
5. **First-Class Suspense**:
- New dedicated hooks: `useSuspenseQuery`, `useSuspenseInfiniteQuery`
- No experimental flag needed
- Data is never undefined at type level
**Migration**: Use the official codemod for automatic migration: `npx @tanstack/query-codemods v5/replace-import-specifier`
### Smart Defaults
Query v5 ships with production-ready defaults:
```typescript
{
staleTime: 0, // Data instantly stale (refetch on mount)
gcTime: 5 * 60_000, // Keep unused cache for 5 minutes
retry: 3, // 3 retries with exponential backoff
refetchOnWindowFocus: true,// Refetch when user returns to tab
refetchOnReconnect: true, // Refetch when network reconnects
}
```
**Philosophy**: React Query is an **async state manager, not a data fetcher**. You provide the Promise; Query manages caching, background updates, and synchronization.
### Client Setup
```typescript
// src/app/providers.tsx
import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query'
import { toast } from './toast' // Your notification system
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 0, // Adjust per-query
gcTime: 5 * 60_000, // 5 minutes (v5: formerly cacheTime)
retry: (failureCount, error) => {
// Don't retry on 401 (authentication errors)
if (error?.response?.status === 401) return false
return failureCount < 3
},
},
},
queryCache: new QueryCache({
onError: (error, query) => {
// Only show toast for background errors (when data exists)
if (query.state.data !== undefined) {
toast.error(`Something went wrong: ${error.message}`)
}
},
}),
})
export function AppProviders({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}
```
**DevTools Setup** (auto-excluded in production):
```typescript
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
```
### Server-Side Rendering Configuration
When using TanStack Query with SSR (Next.js, Remix, TanStack Start), configure server-specific defaults:
```typescript
// Server-side QueryClient configuration
export function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
// Server: Don't retry on server (fail fast)
retry: typeof window === 'undefined' ? 0 : 3,
// Server: Data is always fresh when rendered
staleTime: 60_000, // 1 minute
},
},
})
}
```
**Server vs Client Defaults:**
| Option | Client Default | Server Recommended | Why |
|--------|---------------|-------------------|-----|
| `retry` | 3 | 0 | Server should fail fast, not retry loops |
| `staleTime` | 0 | 60_000+ | Server-rendered data is fresh |
| `gcTime` | 5 min | Infinity | No garbage collection needed on server |
| `refetchOnWindowFocus` | true | false | No window on server |
| `refetchOnReconnect` | true | false | No reconnect on server |
**Important:** In SPA-only apps (TanStack Router + Vite), you don't need these server defaults. They're only relevant for SSR frameworks.
### Streaming SSR (Experimental)
For Next.js App Router, `@tanstack/react-query-next-experimental` enables streaming:
```bash
pnpm add @tanstack/react-query-next-experimental
```
**Setup:**
```typescript
// app/providers.tsx
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryStreamedHydration } from '@tanstack/react-query-next-experimental'
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { staleTime: 60_000 },
},
})
}
let browserQueryClient: QueryClient | undefined
function getQueryClient() {
if (typeof window === 'undefined') {
return makeQueryClient() // Server: always new
}
return (browserQueryClient ??= makeQueryClient()) // Browser: singleton
}
export function Providers({ children }: { children: React.ReactNode }) {
const queryClient = getQueryClient()
return (
<QueryClientProvider client={queryClient}>
<ReactQueryStreamedHydration>{children}</ReactQueryStreamedHydration>
</QueryClientProvider>
)
}
```
**Usage in Client Components:**
```typescript
'use client'
import { useSuspenseQuery } from '@tanstack/react-query'
export function UserProfile({ userId }: { userId: string }) {
// No prefetch needed! Data streams from server
const { data: user } = useSuspenseQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
})
return <div>{user.name}</div>
}
```
**Benefits:**
- Skip manual prefetching in Server Components
- Data streams to client as it resolves
- Suspense boundaries show loading states naturally
**Limitations:**
- Next.js App Router only (experimental)
- Not for TanStack Router SPAs (use route loaders instead)
### Server Components Integration
**When you have React Server Components (RSC)**, how does TanStack Query fit?
#### The Mental Model
Think of Server Components as **another framework loader** (like route loaders):
| Feature | Server Components | TanStack Query |
|---------|-------------------|----------------|
| Initial data fetch | Yes (server) | Yes (client prefetch) |
| Client mutations | No | Yes |
| Background refetch | No | Yes |
| Optimistic updates | No | Yes |
| Real-time updates | No | Yes |
| Cache management | No | Yes |
#### When TanStack Query is Still Valuable
Even in RSC-heavy apps, Query remains essential for:
1. **Client-Side Mutations**
```typescript
// Server Component fetches, Client handles mutations
export default async function PostPage({ params }) {
const post = await fetchPost(params.id) // Server fetch
return <PostWithComments post={post} /> // Client mutations
}
'use client'
function PostWithComments({ post }) {
const addComment = useMutation({ ... }) // Still need Query!
// ...
}
```
2. **Background Refetching After Initial Load**
```typescript
// Initial: Server Component renders with fresh data
// After: Query keeps data fresh on client
```
3. **Optimistic Updates**
```typescript
// Can't do optimistic updates with Server Components alone
const likeMutation = useMutation({
mutationFn: likePost,
onMutate: async () => {
// Optimistic update - only possible with Query
},
})
```
4. **Real-Time Updates**
```typescript
// WebSocket data, polling, etc. - client-only
useQuery({
queryKey: ['notifications'],
queryFn: fetchNotifications,
refetchInterval: 30_000, // Real-time polling
})
```
#### Recommended Pattern
```typescript
// Server Component: Initial fetch
export default async function DashboardPage() {
const initialData = await fetchDashboard()
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<DashboardClient initialData={initialData} />
</HydrationBoundary>
)
}
// Client Component: Mutations + real-time
'use client'
function DashboardClient({ initialData }) {
// Query hydrates from server data, then manages client state
const { data } = useQuery({
queryKey: ['dashboard'],
queryFn: fetchDashboard,
initialData,
})
const updateWidget = useMutation({ ... })
// ...
}
```
#### SPA Recommendation
**For SPA-only apps (TanStack Router + Vite)**: Server Components don't apply. Use TanStack Query as your primary data layer with route loaders for prefetching.
### Query + React 19 Actions
When using React 19 Actions alongside Query, keep responsibilities clear:
#### Complementary Usage
```typescript
// Query: Fetching and caching
const { data: posts } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
})
// Action: Form submission with built-in validation
async function createPostAction(formData: FormData) {
'use server'
const result = await createPost(formData)
return result
}
// After action succeeds, invalidate Query cache
const [state, formAction] = useActionState(async (prev, formData) => {
const result = await createPostAction(formData)
if (result.success) {
queryClient.invalidateQueries({ queryKey: ['posts'] })
}
return result
}, { success: false })
```
#### Decision Matrix
| Use Case | Recommendation |
|----------|----------------|
| Data fetching | Query (`useQuery`) |
| List caching | Query |
| Form submission | Action (`useActionState`) + Query invalidation |
| Button click mutation | Query (`useMutation`) |
| Optimistic update with rollback | Query (`useMutation`) |
| Server-side validation | Action |
| Complex multi-step mutations | Query (`useMutation`) |
#### Rule of Thumb
- **Actions** for form submissions with server-side validation
- **Query** for everything else (fetching, caching, complex mutations)
- **Both** when forms need to update Query cache after success
### Architecture: Feature-Based Colocation
**Recommended pattern**: Group queries with related features, not by file type.
```
src/features/
├── Todos/
│ ├── index.tsx # Feature entry point
│ ├── queries.ts # All React Query logic (keys, functions, hooks)
│ ├── types.ts # TypeScript types
│ └── components/ # Feature-specific components
```
**Export only custom hooks** from query files. Keep query functions and keys private:
```typescript
// features/todos/queries.ts
// 1. Query Key Factory (hierarchical structure)
const todoKeys = {
all: ['todos'] as const,
lists: () => [...todoKeys.all, 'list'] as const,
list: (filters: string) => [...todoKeys.lists(), { filters }] as const,
details: () => [...todoKeys.all, 'detail'] as const,
detail: (id: number) => [...todoKeys.details(), id] as const,
}
// 2. Query Function (private)
const fetchTodos = async (filters: string): Promise<Todo[]> => {
const response = await axios.get('/api/todos', { params: { filters } })
return response.data
}
// 3. Custom Hook (public API)
export const useTodosQuery = (filters: string) => {
return useQuery({
queryKey: todoKeys.list(filters),
queryFn: () => fetchTodos(filters),
staleTime: 30_000, // Fresh for 30 seconds
})
}
```
**Benefits**:
- Prevents key/function mismatches
- Clean public API
- Encapsulation and maintainability
- Easy to locate all query logic for a feature
### Query Key Factories (Essential)
**Structure keys hierarchically** from generic to specific:
```typescript
// ✅ Correct hierarchy
['todos'] // Invalidates everything
['todos', 'list'] // Invalidates all lists
['todos', 'list', { filters }] // Invalidates specific list
['todos', 'detail', 1] // Invalidates specific detail
View on GitHub