Complete collection of AI agent skills including: - Frontend Development (Vue, React, Next.js, Three.js) - Backend Development (NestJS, FastAPI, Node.js) - Mobile Development (React Native, Expo) - Testing (E2E, frontend, webapp) - DevOps (GitHub Actions, CI/CD) - Marketing (SEO, copywriting, analytics) - Security (binary analysis, vulnerability scanning) - And many more... Synchronized from: https://skills.sh/ Co-Authored-By: Claude <noreply@anthropic.com>
1059 lines
30 KiB
Markdown
1059 lines
30 KiB
Markdown
---
|
|
name: tanstack-query
|
|
description: |
|
|
Manage server state in React with TanStack Query v5. Covers useMutationState, simplified optimistic updates, throwOnError, network mode (offline/PWA), and infiniteQueryOptions.
|
|
|
|
Use when setting up data fetching, fixing v4→v5 migration errors (object syntax, gcTime, isPending, keepPreviousData), or debugging SSR/hydration issues with streaming server components.
|
|
user-invocable: true
|
|
---
|
|
|
|
# TanStack Query (React Query) v5
|
|
|
|
**Last Updated**: 2026-01-20
|
|
**Versions**: @tanstack/react-query@5.90.19, @tanstack/react-query-devtools@5.91.2
|
|
**Requires**: React 18.0+ (useSyncExternalStore), TypeScript 4.7+ (recommended)
|
|
|
|
---
|
|
|
|
## v5 New Features
|
|
|
|
### useMutationState - Cross-Component Mutation Tracking
|
|
|
|
Access mutation state from anywhere without prop drilling:
|
|
|
|
```tsx
|
|
import { useMutationState } from '@tanstack/react-query'
|
|
|
|
function GlobalLoadingIndicator() {
|
|
// Get all pending mutations
|
|
const pendingMutations = useMutationState({
|
|
filters: { status: 'pending' },
|
|
select: (mutation) => mutation.state.variables,
|
|
})
|
|
|
|
if (pendingMutations.length === 0) return null
|
|
return <div>Saving {pendingMutations.length} items...</div>
|
|
}
|
|
|
|
// Filter by mutation key
|
|
const todoMutations = useMutationState({
|
|
filters: { mutationKey: ['addTodo'] },
|
|
})
|
|
```
|
|
|
|
### Simplified Optimistic Updates
|
|
|
|
New pattern using `variables` - no cache manipulation, no rollback needed:
|
|
|
|
```tsx
|
|
function TodoList() {
|
|
const { data: todos } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
|
|
|
|
const addTodo = useMutation({
|
|
mutationKey: ['addTodo'],
|
|
mutationFn: (newTodo) => api.addTodo(newTodo),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['todos'] })
|
|
},
|
|
})
|
|
|
|
// Show optimistic UI using variables from pending mutations
|
|
const pendingTodos = useMutationState({
|
|
filters: { mutationKey: ['addTodo'], status: 'pending' },
|
|
select: (mutation) => mutation.state.variables,
|
|
})
|
|
|
|
return (
|
|
<ul>
|
|
{todos?.map(todo => <li key={todo.id}>{todo.title}</li>)}
|
|
{/* Show pending items with visual indicator */}
|
|
{pendingTodos.map((todo, i) => (
|
|
<li key={`pending-${i}`} style={{ opacity: 0.5 }}>{todo.title}</li>
|
|
))}
|
|
</ul>
|
|
)
|
|
}
|
|
```
|
|
|
|
### throwOnError - Error Boundaries
|
|
|
|
Renamed from `useErrorBoundary` (breaking change):
|
|
|
|
```tsx
|
|
import { QueryErrorResetBoundary } from '@tanstack/react-query'
|
|
import { ErrorBoundary } from 'react-error-boundary'
|
|
|
|
function App() {
|
|
return (
|
|
<QueryErrorResetBoundary>
|
|
{({ reset }) => (
|
|
<ErrorBoundary onReset={reset} fallbackRender={({ resetErrorBoundary }) => (
|
|
<div>
|
|
Error! <button onClick={resetErrorBoundary}>Retry</button>
|
|
</div>
|
|
)}>
|
|
<Todos />
|
|
</ErrorBoundary>
|
|
)}
|
|
</QueryErrorResetBoundary>
|
|
)
|
|
}
|
|
|
|
function Todos() {
|
|
const { data } = useQuery({
|
|
queryKey: ['todos'],
|
|
queryFn: fetchTodos,
|
|
throwOnError: true, // ✅ v5 (was useErrorBoundary in v4)
|
|
})
|
|
return <div>{data.map(...)}</div>
|
|
}
|
|
```
|
|
|
|
### Network Mode (Offline/PWA Support)
|
|
|
|
Control behavior when offline:
|
|
|
|
```tsx
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: {
|
|
networkMode: 'offlineFirst', // Use cache when offline
|
|
},
|
|
},
|
|
})
|
|
|
|
// Per-query override
|
|
useQuery({
|
|
queryKey: ['todos'],
|
|
queryFn: fetchTodos,
|
|
networkMode: 'always', // Always try, even offline (for local APIs)
|
|
})
|
|
```
|
|
|
|
| Mode | Behavior |
|
|
|------|----------|
|
|
| `online` (default) | Only fetch when online |
|
|
| `always` | Always try (useful for local/service worker APIs) |
|
|
| `offlineFirst` | Use cache first, fetch when online |
|
|
|
|
**Detecting paused state:**
|
|
```tsx
|
|
const { isPending, fetchStatus } = useQuery(...)
|
|
// isPending + fetchStatus === 'paused' = offline, waiting for network
|
|
```
|
|
|
|
### useQueries with Combine
|
|
|
|
Combine results from parallel queries:
|
|
|
|
```tsx
|
|
const results = useQueries({
|
|
queries: userIds.map(id => ({
|
|
queryKey: ['user', id],
|
|
queryFn: () => fetchUser(id),
|
|
})),
|
|
combine: (results) => ({
|
|
data: results.map(r => r.data),
|
|
pending: results.some(r => r.isPending),
|
|
error: results.find(r => r.error)?.error,
|
|
}),
|
|
})
|
|
|
|
// Access combined result
|
|
if (results.pending) return <Loading />
|
|
console.log(results.data) // [user1, user2, user3]
|
|
```
|
|
|
|
### infiniteQueryOptions Helper
|
|
|
|
Type-safe factory for infinite queries (parallel to `queryOptions`):
|
|
|
|
```tsx
|
|
import { infiniteQueryOptions, useInfiniteQuery, prefetchInfiniteQuery } from '@tanstack/react-query'
|
|
|
|
const todosInfiniteOptions = infiniteQueryOptions({
|
|
queryKey: ['todos', 'infinite'],
|
|
queryFn: ({ pageParam }) => fetchTodosPage(pageParam),
|
|
initialPageParam: 0,
|
|
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
|
})
|
|
|
|
// Reuse across hooks
|
|
useInfiniteQuery(todosInfiniteOptions)
|
|
useSuspenseInfiniteQuery(todosInfiniteOptions)
|
|
prefetchInfiniteQuery(queryClient, todosInfiniteOptions)
|
|
```
|
|
|
|
### maxPages - Memory Optimization
|
|
|
|
Limit pages stored in cache for infinite queries:
|
|
|
|
```tsx
|
|
useInfiniteQuery({
|
|
queryKey: ['posts'],
|
|
queryFn: ({ pageParam }) => fetchPosts(pageParam),
|
|
initialPageParam: 0,
|
|
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
|
getPreviousPageParam: (firstPage) => firstPage.prevCursor, // Required with maxPages
|
|
maxPages: 3, // Only keep 3 pages in memory
|
|
})
|
|
```
|
|
|
|
**Note:** `maxPages` requires bi-directional pagination (`getNextPageParam` AND `getPreviousPageParam`).
|
|
|
|
---
|
|
|
|
## Quick Setup
|
|
|
|
```bash
|
|
npm install @tanstack/react-query@latest
|
|
npm install -D @tanstack/react-query-devtools@latest
|
|
```
|
|
|
|
### Step 2: Provider + Config
|
|
|
|
```tsx
|
|
// src/main.tsx
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
|
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: {
|
|
staleTime: 1000 * 60 * 5, // 5 min
|
|
gcTime: 1000 * 60 * 60, // 1 hour (v5: renamed from cacheTime)
|
|
refetchOnWindowFocus: false,
|
|
},
|
|
},
|
|
})
|
|
|
|
<QueryClientProvider client={queryClient}>
|
|
<App />
|
|
<ReactQueryDevtools initialIsOpen={false} />
|
|
</QueryClientProvider>
|
|
```
|
|
|
|
### Step 3: Query + Mutation Hooks
|
|
|
|
```tsx
|
|
// src/hooks/useTodos.ts
|
|
import { useQuery, useMutation, useQueryClient, queryOptions } from '@tanstack/react-query'
|
|
|
|
// Query options factory (v5 pattern)
|
|
export const todosQueryOptions = queryOptions({
|
|
queryKey: ['todos'],
|
|
queryFn: async () => {
|
|
const res = await fetch('/api/todos')
|
|
if (!res.ok) throw new Error('Failed to fetch')
|
|
return res.json()
|
|
},
|
|
})
|
|
|
|
export function useTodos() {
|
|
return useQuery(todosQueryOptions)
|
|
}
|
|
|
|
export function useAddTodo() {
|
|
const queryClient = useQueryClient()
|
|
return useMutation({
|
|
mutationFn: async (newTodo) => {
|
|
const res = await fetch('/api/todos', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(newTodo),
|
|
})
|
|
if (!res.ok) throw new Error('Failed to add')
|
|
return res.json()
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['todos'] })
|
|
},
|
|
})
|
|
}
|
|
|
|
// Usage:
|
|
function TodoList() {
|
|
const { data, isPending, isError, error } = useTodos()
|
|
const { mutate } = useAddTodo()
|
|
|
|
if (isPending) return <div>Loading...</div>
|
|
if (isError) return <div>Error: {error.message}</div>
|
|
return <ul>{data.map(todo => <li key={todo.id}>{todo.title}</li>)}</ul>
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Critical Rules
|
|
|
|
### Always Do
|
|
|
|
✅ **Use object syntax for all hooks**
|
|
```tsx
|
|
// v5 ONLY supports this:
|
|
useQuery({ queryKey, queryFn, ...options })
|
|
useMutation({ mutationFn, ...options })
|
|
```
|
|
|
|
✅ **Use array query keys**
|
|
```tsx
|
|
queryKey: ['todos'] // List
|
|
queryKey: ['todos', id] // Detail
|
|
queryKey: ['todos', { filter }] // Filtered
|
|
```
|
|
|
|
✅ **Configure staleTime appropriately**
|
|
```tsx
|
|
staleTime: 1000 * 60 * 5 // 5 min - prevents excessive refetches
|
|
```
|
|
|
|
✅ **Use isPending for initial loading state**
|
|
```tsx
|
|
if (isPending) return <Loading />
|
|
// isPending = no data yet AND fetching
|
|
```
|
|
|
|
✅ **Throw errors in queryFn**
|
|
```tsx
|
|
if (!response.ok) throw new Error('Failed')
|
|
```
|
|
|
|
✅ **Invalidate queries after mutations**
|
|
```tsx
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['todos'] })
|
|
}
|
|
```
|
|
|
|
✅ **Use queryOptions factory for reusable patterns**
|
|
```tsx
|
|
const opts = queryOptions({ queryKey, queryFn })
|
|
useQuery(opts)
|
|
useSuspenseQuery(opts)
|
|
prefetchQuery(opts)
|
|
```
|
|
|
|
✅ **Use gcTime (not cacheTime)**
|
|
```tsx
|
|
gcTime: 1000 * 60 * 60 // 1 hour
|
|
```
|
|
|
|
### Never Do
|
|
|
|
❌ **Never use v4 array/function syntax**
|
|
```tsx
|
|
// v4 (removed in v5):
|
|
useQuery(['todos'], fetchTodos, options) // ❌
|
|
|
|
// v5 (correct):
|
|
useQuery({ queryKey: ['todos'], queryFn: fetchTodos }) // ✅
|
|
```
|
|
|
|
❌ **Never use query callbacks (onSuccess, onError, onSettled in queries)**
|
|
```tsx
|
|
// v5 removed these from queries:
|
|
useQuery({
|
|
queryKey: ['todos'],
|
|
queryFn: fetchTodos,
|
|
onSuccess: (data) => {}, // ❌ Removed in v5
|
|
})
|
|
|
|
// Use useEffect instead:
|
|
const { data } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
|
|
useEffect(() => {
|
|
if (data) {
|
|
// Do something
|
|
}
|
|
}, [data])
|
|
|
|
// Or use mutation callbacks (still supported):
|
|
useMutation({
|
|
mutationFn: addTodo,
|
|
onSuccess: () => {}, // ✅ Still works for mutations
|
|
})
|
|
```
|
|
|
|
❌ **Never use deprecated options**
|
|
```tsx
|
|
// Deprecated in v5:
|
|
cacheTime: 1000 // ❌ Use gcTime instead
|
|
isLoading: true // ❌ Meaning changed, use isPending
|
|
keepPreviousData: true // ❌ Use placeholderData instead
|
|
onSuccess: () => {} // ❌ Removed from queries
|
|
useErrorBoundary: true // ❌ Use throwOnError instead
|
|
```
|
|
|
|
❌ **Never assume isLoading means "no data yet"**
|
|
```tsx
|
|
// v5 changed this:
|
|
isLoading = isPending && isFetching // ❌ Now means "pending AND fetching"
|
|
isPending = no data yet // ✅ Use this for initial load
|
|
```
|
|
|
|
❌ **Never forget initialPageParam for infinite queries**
|
|
```tsx
|
|
// v5 requires this:
|
|
useInfiniteQuery({
|
|
queryKey: ['projects'],
|
|
queryFn: ({ pageParam }) => fetchProjects(pageParam),
|
|
initialPageParam: 0, // ✅ Required in v5
|
|
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
|
})
|
|
```
|
|
|
|
❌ **Never use enabled with useSuspenseQuery**
|
|
```tsx
|
|
// Not allowed:
|
|
useSuspenseQuery({
|
|
queryKey: ['todo', id],
|
|
queryFn: () => fetchTodo(id),
|
|
enabled: !!id, // ❌ Not available with suspense
|
|
})
|
|
|
|
// Use conditional rendering instead:
|
|
{id && <TodoComponent id={id} />}
|
|
```
|
|
|
|
❌ **Never rely on refetchOnMount: false for errored queries**
|
|
```tsx
|
|
// Doesn't work - errors are always stale
|
|
useQuery({
|
|
queryKey: ['data'],
|
|
queryFn: failingFetch,
|
|
refetchOnMount: false, // ❌ Ignored when query has error
|
|
})
|
|
|
|
// Use retryOnMount instead
|
|
useQuery({
|
|
queryKey: ['data'],
|
|
queryFn: failingFetch,
|
|
refetchOnMount: false,
|
|
retryOnMount: false, // ✅ Prevents refetch for errored queries
|
|
retry: 0,
|
|
})
|
|
```
|
|
|
|
---
|
|
|
|
## Known Issues Prevention
|
|
|
|
This skill prevents **16 documented issues** from v5 migration, SSR/hydration bugs, and common mistakes:
|
|
|
|
### Issue #1: Object Syntax Required
|
|
**Error**: `useQuery is not a function` or type errors
|
|
**Source**: [v5 Migration Guide](https://tanstack.com/query/latest/docs/framework/react/guides/migrating-to-v5#removed-overloads-in-favor-of-object-syntax)
|
|
**Why It Happens**: v5 removed all function overloads, only object syntax works
|
|
**Prevention**: Always use `useQuery({ queryKey, queryFn, ...options })`
|
|
|
|
**Before (v4):**
|
|
```tsx
|
|
useQuery(['todos'], fetchTodos, { staleTime: 5000 })
|
|
```
|
|
|
|
**After (v5):**
|
|
```tsx
|
|
useQuery({
|
|
queryKey: ['todos'],
|
|
queryFn: fetchTodos,
|
|
staleTime: 5000
|
|
})
|
|
```
|
|
|
|
### Issue #2: Query Callbacks Removed
|
|
**Error**: Callbacks don't run, TypeScript errors
|
|
**Source**: [v5 Breaking Changes](https://tanstack.com/query/latest/docs/framework/react/guides/migrating-to-v5#callbacks-on-usequery-and-queryobserver-have-been-removed)
|
|
**Why It Happens**: onSuccess, onError, onSettled removed from queries (still work in mutations)
|
|
**Prevention**: Use `useEffect` for side effects, or move logic to mutation callbacks
|
|
|
|
**Before (v4):**
|
|
```tsx
|
|
useQuery({
|
|
queryKey: ['todos'],
|
|
queryFn: fetchTodos,
|
|
onSuccess: (data) => {
|
|
console.log('Todos loaded:', data)
|
|
},
|
|
})
|
|
```
|
|
|
|
**After (v5):**
|
|
```tsx
|
|
const { data } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
|
|
useEffect(() => {
|
|
if (data) {
|
|
console.log('Todos loaded:', data)
|
|
}
|
|
}, [data])
|
|
```
|
|
|
|
### Issue #3: Status Loading → Pending
|
|
**Error**: UI shows wrong loading state
|
|
**Source**: [v5 Migration: isLoading renamed](https://tanstack.com/query/latest/docs/framework/react/guides/migrating-to-v5#isloading-and-isfetching-flags)
|
|
**Why It Happens**: `status: 'loading'` renamed to `status: 'pending'`, `isLoading` meaning changed
|
|
**Prevention**: Use `isPending` for initial load, `isLoading` for "pending AND fetching"
|
|
|
|
**Before (v4):**
|
|
```tsx
|
|
const { data, isLoading } = useQuery(...)
|
|
if (isLoading) return <div>Loading...</div>
|
|
```
|
|
|
|
**After (v5):**
|
|
```tsx
|
|
const { data, isPending, isLoading } = useQuery(...)
|
|
if (isPending) return <div>Loading...</div>
|
|
// isLoading = isPending && isFetching (fetching for first time)
|
|
```
|
|
|
|
### Issue #4: cacheTime → gcTime
|
|
**Error**: `cacheTime is not a valid option`
|
|
**Source**: [v5 Migration: gcTime](https://tanstack.com/query/latest/docs/framework/react/guides/migrating-to-v5#cachetime-has-been-replaced-by-gctime)
|
|
**Why It Happens**: Renamed to better reflect "garbage collection time"
|
|
**Prevention**: Use `gcTime` instead of `cacheTime`
|
|
|
|
**Before (v4):**
|
|
```tsx
|
|
useQuery({
|
|
queryKey: ['todos'],
|
|
queryFn: fetchTodos,
|
|
cacheTime: 1000 * 60 * 60,
|
|
})
|
|
```
|
|
|
|
**After (v5):**
|
|
```tsx
|
|
useQuery({
|
|
queryKey: ['todos'],
|
|
queryFn: fetchTodos,
|
|
gcTime: 1000 * 60 * 60,
|
|
})
|
|
```
|
|
|
|
### Issue #5: useSuspenseQuery + enabled
|
|
**Error**: Type error, enabled option not available
|
|
**Source**: [GitHub Discussion #6206](https://github.com/TanStack/query/discussions/6206)
|
|
**Why It Happens**: Suspense guarantees data is available, can't conditionally disable
|
|
**Prevention**: Use conditional rendering instead of `enabled` option
|
|
|
|
**Before (v4/incorrect):**
|
|
```tsx
|
|
useSuspenseQuery({
|
|
queryKey: ['todo', id],
|
|
queryFn: () => fetchTodo(id),
|
|
enabled: !!id, // ❌ Not allowed
|
|
})
|
|
```
|
|
|
|
**After (v5/correct):**
|
|
```tsx
|
|
// Conditional rendering:
|
|
{id ? (
|
|
<TodoComponent id={id} />
|
|
) : (
|
|
<div>No ID selected</div>
|
|
)}
|
|
|
|
// Inside TodoComponent:
|
|
function TodoComponent({ id }: { id: number }) {
|
|
const { data } = useSuspenseQuery({
|
|
queryKey: ['todo', id],
|
|
queryFn: () => fetchTodo(id),
|
|
// No enabled option needed
|
|
})
|
|
return <div>{data.title}</div>
|
|
}
|
|
```
|
|
|
|
### Issue #6: initialPageParam Required
|
|
**Error**: `initialPageParam is required` type error
|
|
**Source**: [v5 Migration: Infinite Queries](https://tanstack.com/query/latest/docs/framework/react/guides/migrating-to-v5#new-required-initialPageParam-option)
|
|
**Why It Happens**: v4 passed `undefined` as first pageParam, v5 requires explicit value
|
|
**Prevention**: Always specify `initialPageParam` for infinite queries
|
|
|
|
**Before (v4):**
|
|
```tsx
|
|
useInfiniteQuery({
|
|
queryKey: ['projects'],
|
|
queryFn: ({ pageParam = 0 }) => fetchProjects(pageParam),
|
|
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
|
})
|
|
```
|
|
|
|
**After (v5):**
|
|
```tsx
|
|
useInfiniteQuery({
|
|
queryKey: ['projects'],
|
|
queryFn: ({ pageParam }) => fetchProjects(pageParam),
|
|
initialPageParam: 0, // ✅ Required
|
|
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
|
})
|
|
```
|
|
|
|
### Issue #7: keepPreviousData Removed
|
|
**Error**: `keepPreviousData is not a valid option`
|
|
**Source**: [v5 Migration: placeholderData](https://tanstack.com/query/latest/docs/framework/react/guides/migrating-to-v5#removed-keeppreviousdata-in-favor-of-placeholderdata-identity-function)
|
|
**Why It Happens**: Replaced with more flexible `placeholderData` function
|
|
**Prevention**: Use `placeholderData: keepPreviousData` helper
|
|
|
|
**Before (v4):**
|
|
```tsx
|
|
useQuery({
|
|
queryKey: ['todos', page],
|
|
queryFn: () => fetchTodos(page),
|
|
keepPreviousData: true,
|
|
})
|
|
```
|
|
|
|
**After (v5):**
|
|
```tsx
|
|
import { keepPreviousData } from '@tanstack/react-query'
|
|
|
|
useQuery({
|
|
queryKey: ['todos', page],
|
|
queryFn: () => fetchTodos(page),
|
|
placeholderData: keepPreviousData,
|
|
})
|
|
```
|
|
|
|
### Issue #8: TypeScript Error Type Default
|
|
**Error**: Type errors with error handling
|
|
**Source**: [v5 Migration: Error Types](https://tanstack.com/query/latest/docs/framework/react/guides/migrating-to-v5#typeerror-is-now-the-default-error)
|
|
**Why It Happens**: v4 used `unknown`, v5 defaults to `Error` type
|
|
**Prevention**: If throwing non-Error types, specify error type explicitly
|
|
|
|
**Before (v4 - error was unknown):**
|
|
```tsx
|
|
const { error } = useQuery({
|
|
queryKey: ['data'],
|
|
queryFn: async () => {
|
|
if (Math.random() > 0.5) throw 'custom error string'
|
|
return data
|
|
},
|
|
})
|
|
// error: unknown
|
|
```
|
|
|
|
**After (v5 - specify custom error type):**
|
|
```tsx
|
|
const { error } = useQuery<DataType, string>({
|
|
queryKey: ['data'],
|
|
queryFn: async () => {
|
|
if (Math.random() > 0.5) throw 'custom error string'
|
|
return data
|
|
},
|
|
})
|
|
// error: string | null
|
|
|
|
// Or better: always throw Error objects
|
|
const { error } = useQuery({
|
|
queryKey: ['data'],
|
|
queryFn: async () => {
|
|
if (Math.random() > 0.5) throw new Error('custom error')
|
|
return data
|
|
},
|
|
})
|
|
// error: Error | null (default)
|
|
```
|
|
|
|
### Issue #9: Streaming Server Components Hydration Error
|
|
**Error**: `Hydration failed because the initial UI does not match what was rendered on the server`
|
|
**Source**: [GitHub Issue #9642](https://github.com/TanStack/query/issues/9642)
|
|
**Affects**: v5.82.0+ with streaming SSR (void prefetch pattern)
|
|
**Why It Happens**: Race condition where `hydrate()` resolves synchronously but `query.fetch()` creates async retryer, causing isFetching/isStale mismatch between server and client
|
|
**Prevention**: Don't conditionally render based on `fetchStatus` with `useSuspenseQuery` and streaming prefetch, OR await prefetch instead of void pattern
|
|
|
|
**Before (causes hydration error):**
|
|
```tsx
|
|
// Server: void prefetch
|
|
streamingQueryClient.prefetchQuery({ queryKey: ['data'], queryFn: getData });
|
|
|
|
// Client: conditional render on fetchStatus
|
|
const { data, isFetching } = useSuspenseQuery({ queryKey: ['data'], queryFn: getData });
|
|
return <>{data && <div>{data}</div>} {isFetching && <Loading />}</>;
|
|
```
|
|
|
|
**After (workaround):**
|
|
```tsx
|
|
// Option 1: Await prefetch
|
|
await streamingQueryClient.prefetchQuery({ queryKey: ['data'], queryFn: getData });
|
|
|
|
// Option 2: Don't render based on fetchStatus with Suspense
|
|
const { data } = useSuspenseQuery({ queryKey: ['data'], queryFn: getData });
|
|
return <div>{data}</div>; // No conditional on isFetching
|
|
```
|
|
|
|
**Status**: Known issue, being investigated by maintainers. Requires implementation of `getServerSnapshot` in useSyncExternalStore.
|
|
|
|
### Issue #10: useQuery Hydration Error with Prefetching
|
|
**Error**: Text content mismatch during hydration
|
|
**Source**: [GitHub Issue #9399](https://github.com/TanStack/query/issues/9399)
|
|
**Affects**: v5.x with server-side prefetching
|
|
**Why It Happens**: `tryResolveSync` detects resolved promises in RSC payload and extracts data synchronously during hydration, bypassing normal pending state
|
|
**Prevention**: Use `useSuspenseQuery` instead of `useQuery` for SSR, or avoid conditional rendering based on `isLoading`
|
|
|
|
**Before (causes hydration error):**
|
|
```tsx
|
|
// Server Component
|
|
const queryClient = getServerQueryClient();
|
|
await queryClient.prefetchQuery({ queryKey: ['todos'], queryFn: fetchTodos });
|
|
|
|
// Client Component
|
|
function Todos() {
|
|
const { data, isLoading } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos });
|
|
if (isLoading) return <div>Loading...</div>; // Server renders this
|
|
return <div>{data.length} todos</div>; // Client hydrates with this
|
|
}
|
|
```
|
|
|
|
**After (workaround):**
|
|
```tsx
|
|
// Use useSuspenseQuery instead
|
|
function Todos() {
|
|
const { data } = useSuspenseQuery({ queryKey: ['todos'], queryFn: fetchTodos });
|
|
return <div>{data.length} todos</div>;
|
|
}
|
|
```
|
|
|
|
**Status**: "At the top of my OSS list of things to fix" - maintainer Ephem (Nov 2025). Requires implementing `getServerSnapshot` in useSyncExternalStore.
|
|
|
|
### Issue #11: refetchOnMount Not Respected for Errored Queries
|
|
**Error**: Queries refetch on mount despite `refetchOnMount: false`
|
|
**Source**: [GitHub Issue #10018](https://github.com/TanStack/query/issues/10018)
|
|
**Affects**: v5.90.16+
|
|
**Why It Happens**: Errored queries with no data are always treated as stale. This is intentional to avoid permanently showing error states
|
|
**Prevention**: Use `retryOnMount: false` instead of (or in addition to) `refetchOnMount: false`
|
|
|
|
**Before (refetches despite setting):**
|
|
```tsx
|
|
const { data, error } = useQuery({
|
|
queryKey: ['data'],
|
|
queryFn: () => { throw new Error('Fails') },
|
|
refetchOnMount: false, // Ignored when query is in error state
|
|
retry: 0,
|
|
});
|
|
// Query refetches every time component mounts
|
|
```
|
|
|
|
**After (correct):**
|
|
```tsx
|
|
const { data, error } = useQuery({
|
|
queryKey: ['data'],
|
|
queryFn: failingFetch,
|
|
refetchOnMount: false,
|
|
retryOnMount: false, // ✅ Prevents refetch on mount for errored queries
|
|
retry: 0,
|
|
});
|
|
```
|
|
|
|
**Status**: Documented behavior (intentional). The name `retryOnMount` is slightly misleading - it controls whether errored queries trigger a new fetch on mount, not automatic retries.
|
|
|
|
### Issue #12: Mutation Callback Signature Breaking Change (v5.89.0)
|
|
**Error**: TypeScript errors in mutation callbacks
|
|
**Source**: [GitHub Issue #9660](https://github.com/TanStack/query/issues/9660)
|
|
**Affects**: v5.89.0+
|
|
**Why It Happens**: `onMutateResult` parameter added between `variables` and `context`, changing callback signatures from 3 params to 4
|
|
**Prevention**: Update all mutation callbacks to accept 4 parameters instead of 3
|
|
|
|
**Before (v5.88 and earlier):**
|
|
```tsx
|
|
useMutation({
|
|
mutationFn: addTodo,
|
|
onError: (error, variables, context) => {
|
|
// context is now onMutateResult, missing final context param
|
|
},
|
|
onSuccess: (data, variables, context) => {
|
|
// Same issue
|
|
}
|
|
});
|
|
```
|
|
|
|
**After (v5.89.0+):**
|
|
```tsx
|
|
useMutation({
|
|
mutationFn: addTodo,
|
|
onError: (error, variables, onMutateResult, context) => {
|
|
// onMutateResult = return value from onMutate
|
|
// context = mutation function context
|
|
},
|
|
onSuccess: (data, variables, onMutateResult, context) => {
|
|
// Correct signature with 4 parameters
|
|
}
|
|
});
|
|
```
|
|
|
|
**Note**: If you don't use `onMutate`, the `onMutateResult` parameter will be undefined. This breaking change was introduced in a patch version.
|
|
|
|
### Issue #13: Readonly Query Keys Break Partial Matching (v5.90.8)
|
|
**Error**: `Type 'readonly ["todos", string]' is not assignable to type '["todos", string]'`
|
|
**Source**: [GitHub Issue #9871](https://github.com/TanStack/query/issues/9871) | Fixed in [PR #9872](https://github.com/TanStack/query/pull/9872)
|
|
**Affects**: v5.90.8 only (fixed in v5.90.9)
|
|
**Why It Happens**: Partial query matching broke TypeScript types for readonly query keys (using `as const`)
|
|
**Prevention**: Upgrade to v5.90.9+ or use type assertions if stuck on v5.90.8
|
|
|
|
**Before (v5.90.8 - TypeScript error):**
|
|
```tsx
|
|
export function todoQueryKey(id?: string) {
|
|
return id ? ['todos', id] as const : ['todos'] as const;
|
|
}
|
|
// Type: readonly ['todos', string] | readonly ['todos']
|
|
|
|
useMutation({
|
|
mutationFn: addTodo,
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({
|
|
queryKey: todoQueryKey('123')
|
|
// Error: readonly ['todos', string] not assignable to ['todos', string]
|
|
});
|
|
}
|
|
});
|
|
```
|
|
|
|
**After (v5.90.9+):**
|
|
```tsx
|
|
// Works correctly with readonly types
|
|
queryClient.invalidateQueries({
|
|
queryKey: todoQueryKey('123') // ✅ No type error
|
|
});
|
|
```
|
|
|
|
**Status**: Fixed in v5.90.9. Particularly affected users of code generators like `openapi-react-query` that produce readonly query keys.
|
|
|
|
### Issue #14: useMutationState Type Inference Lost
|
|
**Error**: `mutation.state.variables` typed as `unknown` instead of actual type
|
|
**Source**: [GitHub Issue #9825](https://github.com/TanStack/query/issues/9825)
|
|
**Affects**: All v5.x versions
|
|
**Why It Happens**: Fuzzy mutation key matching prevents guaranteed type inference (same issue as `queryClient.getQueryCache().find()`)
|
|
**Prevention**: Explicitly cast types in the `select` callback
|
|
|
|
**Before (type inference doesn't work):**
|
|
```tsx
|
|
const addTodo = useMutation({
|
|
mutationKey: ['addTodo'],
|
|
mutationFn: (todo: Todo) => api.addTodo(todo),
|
|
});
|
|
|
|
const pendingTodos = useMutationState({
|
|
filters: { mutationKey: ['addTodo'], status: 'pending' },
|
|
select: (mutation) => {
|
|
return mutation.state.variables; // Type: unknown
|
|
},
|
|
});
|
|
```
|
|
|
|
**After (with explicit cast):**
|
|
```tsx
|
|
const pendingTodos = useMutationState({
|
|
filters: { mutationKey: ['addTodo'], status: 'pending' },
|
|
select: (mutation) => mutation.state.variables as Todo,
|
|
});
|
|
// Or cast the entire state:
|
|
select: (mutation) => mutation.state as MutationState<Todo, Error, Todo, unknown>
|
|
```
|
|
|
|
**Status**: Known limitation of fuzzy matching. No planned fix.
|
|
|
|
### Issue #15: Query Cancellation in StrictMode with fetchQuery
|
|
**Error**: `CancelledError` when using `fetchQuery()` with `useQuery`
|
|
**Source**: [GitHub Issue #9798](https://github.com/TanStack/query/issues/9798)
|
|
**Affects**: Development only (React StrictMode)
|
|
**Why It Happens**: StrictMode causes double mount/unmount. When `useQuery` unmounts and is the last observer, it cancels the query even if `fetchQuery()` is also running
|
|
**Prevention**: This is expected development-only behavior. Doesn't affect production
|
|
|
|
**Example:**
|
|
```tsx
|
|
async function loadData() {
|
|
try {
|
|
const data = await queryClient.fetchQuery({
|
|
queryKey: ['data'],
|
|
queryFn: fetchData,
|
|
});
|
|
console.log('Loaded:', data); // Never logs in StrictMode
|
|
} catch (error) {
|
|
console.error('Failed:', error); // CancelledError
|
|
}
|
|
}
|
|
|
|
function Component() {
|
|
const { data } = useQuery({ queryKey: ['data'], queryFn: fetchData });
|
|
// In StrictMode, component unmounts/remounts, cancelling fetchQuery
|
|
}
|
|
```
|
|
|
|
**Workaround:**
|
|
```tsx
|
|
// Keep query observed with staleTime
|
|
const { data } = useQuery({
|
|
queryKey: ['data'],
|
|
queryFn: fetchData,
|
|
staleTime: Infinity, // Keeps query active
|
|
});
|
|
```
|
|
|
|
**Status**: Expected StrictMode behavior, not a bug. Production builds are unaffected.
|
|
|
|
### Issue #16: invalidateQueries Only Refetches Active Queries
|
|
**Error**: Inactive queries not refetching despite `invalidateQueries()` call
|
|
**Source**: [GitHub Issue #9531](https://github.com/TanStack/query/issues/9531)
|
|
**Affects**: All v5.x versions
|
|
**Why It Happens**: Documentation was misleading - `invalidateQueries()` only refetches "active" queries by default, not "all" queries
|
|
**Prevention**: Use `refetchType: 'all'` to force refetch of inactive queries
|
|
|
|
**Default behavior:**
|
|
```tsx
|
|
// Only active queries (currently being observed) will refetch
|
|
queryClient.invalidateQueries({ queryKey: ['todos'] });
|
|
```
|
|
|
|
**To refetch inactive queries:**
|
|
```tsx
|
|
queryClient.invalidateQueries({
|
|
queryKey: ['todos'],
|
|
refetchType: 'all' // Refetch active AND inactive
|
|
});
|
|
```
|
|
|
|
**Status**: Documentation fixed to clarify "active" queries. This is the intended behavior.
|
|
|
|
---
|
|
|
|
## Community Tips
|
|
|
|
> **Note**: These tips come from community experts and maintainer blogs. Verify against your version.
|
|
|
|
### Tip: Query Options with Multiple Listeners
|
|
|
|
**Source**: [TkDodo's Blog - API Design Lessons](https://tkdodo.eu/blog/react-query-api-design-lessons-learned) | **Confidence**: HIGH
|
|
**Applies to**: v5.27.3+
|
|
|
|
When multiple components use the same query with different options (like `staleTime`), the "last write wins" rule applies for future fetches, but the current in-flight query uses its original options. This can cause unexpected behavior when components mount at different times.
|
|
|
|
**Example of unexpected behavior:**
|
|
```tsx
|
|
// Component A mounts first
|
|
function ComponentA() {
|
|
const { data } = useQuery({
|
|
queryKey: ['todos'],
|
|
queryFn: fetchTodos,
|
|
staleTime: 5000, // Applied initially
|
|
});
|
|
}
|
|
|
|
// Component B mounts while A's query is in-flight
|
|
function ComponentB() {
|
|
const { data } = useQuery({
|
|
queryKey: ['todos'],
|
|
queryFn: fetchTodos,
|
|
staleTime: 60000, // Won't affect current fetch, only future ones
|
|
});
|
|
}
|
|
```
|
|
|
|
**Recommended approach:**
|
|
```tsx
|
|
// Write options as functions that reference latest values
|
|
const getStaleTime = () => shouldUseLongCache ? 60000 : 5000;
|
|
|
|
useQuery({
|
|
queryKey: ['todos'],
|
|
queryFn: fetchTodos,
|
|
staleTime: getStaleTime(), // Evaluated on each render
|
|
});
|
|
```
|
|
|
|
### Tip: refetch() is NOT for Changed Parameters
|
|
|
|
**Source**: [Avoiding Common Mistakes with TanStack Query](https://www.buncolak.com/posts/avoiding-common-mistakes-with-tanstack-query-part-1/) | **Confidence**: HIGH
|
|
|
|
The `refetch()` function should ONLY be used for refreshing with the same parameters (like a manual "reload" button). For new parameters (filters, page numbers, search terms, etc.), include them in the query key instead.
|
|
|
|
**Anti-pattern:**
|
|
```tsx
|
|
// ❌ Wrong - using refetch() for different parameters
|
|
const [page, setPage] = useState(1);
|
|
const { data, refetch } = useQuery({
|
|
queryKey: ['todos'], // Same key for all pages
|
|
queryFn: () => fetchTodos(page),
|
|
});
|
|
|
|
// This refetches with OLD page value, not new one
|
|
<button onClick={() => { setPage(2); refetch(); }}>Next</button>
|
|
```
|
|
|
|
**Correct pattern:**
|
|
```tsx
|
|
// ✅ Correct - include parameters in query key
|
|
const [page, setPage] = useState(1);
|
|
const { data } = useQuery({
|
|
queryKey: ['todos', page], // Key changes with page
|
|
queryFn: () => fetchTodos(page),
|
|
// Query automatically refetches when page changes
|
|
});
|
|
|
|
<button onClick={() => setPage(2)}>Next</button> // Just update state
|
|
```
|
|
|
|
**When to use refetch():**
|
|
```tsx
|
|
// ✅ Manual refresh of same data (refresh button)
|
|
const { data, refetch } = useQuery({
|
|
queryKey: ['todos'],
|
|
queryFn: fetchTodos,
|
|
});
|
|
|
|
<button onClick={() => refetch()}>Refresh</button> // Same parameters
|
|
```
|
|
|
|
---
|
|
|
|
## Key Patterns
|
|
|
|
**Dependent Queries** (Query B waits for Query A):
|
|
```tsx
|
|
const { data: posts } = useQuery({
|
|
queryKey: ['users', userId, 'posts'],
|
|
queryFn: () => fetchUserPosts(userId),
|
|
enabled: !!user, // Wait for user
|
|
})
|
|
```
|
|
|
|
**Parallel Queries** (fetch multiple at once):
|
|
```tsx
|
|
const results = useQueries({
|
|
queries: ids.map(id => ({ queryKey: ['todos', id], queryFn: () => fetchTodo(id) })),
|
|
})
|
|
```
|
|
|
|
**Prefetching** (preload on hover):
|
|
```tsx
|
|
queryClient.prefetchQuery({ queryKey: ['todo', id], queryFn: () => fetchTodo(id) })
|
|
```
|
|
|
|
**Infinite Scroll** (useInfiniteQuery):
|
|
```tsx
|
|
useInfiniteQuery({
|
|
queryKey: ['todos', 'infinite'],
|
|
queryFn: ({ pageParam }) => fetchTodosPage(pageParam),
|
|
initialPageParam: 0, // Required in v5
|
|
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
|
})
|
|
```
|
|
|
|
**Query Cancellation** (auto-cancel on queryKey change):
|
|
```tsx
|
|
queryFn: async ({ signal }) => {
|
|
const res = await fetch(`/api/todos?q=${search}`, { signal })
|
|
return res.json()
|
|
}
|
|
```
|
|
|
|
**Data Transformation** (select):
|
|
```tsx
|
|
select: (data) => data.filter(todo => todo.completed)
|
|
```
|
|
|
|
**Avoid Request Waterfalls**: Fetch in parallel when possible (don't chain queries unless truly dependent)
|
|
|
|
---
|
|
|
|
**Official Docs**: https://tanstack.com/query/latest | **v5 Migration**: https://tanstack.com/query/latest/docs/framework/react/guides/migrating-to-v5 | **GitHub**: https://github.com/TanStack/query | **Context7**: `/websites/tanstack_query`
|