TanStack Query (React Query) v5
SkillDev toolsManage server state in React with TanStack Query v5. Covers useMutationState, simplified optimistic updates, throwOnError, network mode (offline/PWA), and infiniteQueryOptions.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the TanStack Query (React Query) v5 skill
What this skill tells your AI
The instructions your AI receives, as published by dennislee928/ethic-latex in .claude/skills/tanstack-query/SKILL.md and read by ahel’s review.
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:
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:
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):
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:
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:
const { isPending, fetchStatus } = useQuery(...)
// isPending + fetchStatus === 'paused' = offline, waiting for network
useQueries with Combine
Combine results from parallel queries:
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):
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:
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
npm install @tanstack/react-query@latest
npm install -D @tanstack/react-query-devtools@latest
Step 2: Provider + Config
// 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
// 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
// v5 ONLY supports this:
useQuery({ queryKey, queryFn, ...options })
useMutation({ mutationFn, ...options })
✅ Use array query keys
queryKey: ['todos'] // List
queryKey: ['todos', id] // Detail
queryKey: ['todos', { filter }] // Filtered
✅ Configure staleTime appropriately
staleTime: 1000 * 60 * 5 // 5 min - prevents excessive refetches
✅ Use isPending for initial loading state
if (isPending) return <Loading />
// isPending = no data yet AND fetching
✅ Throw errors in queryFn
if (!response.ok) throw new Error('Failed')
✅ Invalidate queries after mutations
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
}
✅ Use queryOptions factory for reusable patterns
const opts = queryOptions({ queryKey, queryFn })
useQuery(opts)
useSuspenseQuery(opts)
prefetchQuery(opts)
✅ Use gcTime (not cacheTime)
gcTime: 1000 * 60 * 60 // 1 hour
Never Do
❌ Never use v4 array/function syntax
// v4 (removed in v5):
useQuery(['todos'], fetchTodos, options) // ❌
// v5 (correct):
useQuery({ queryKey: ['todos'], queryFn: fetchTodos }) // ✅
❌ Never use query callbacks (onSuccess, onError, onSettled in queries)
// 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
// 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"
// 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
// v5 requires this:
useInfiniteQuery({
queryKey: ['projects'],
queryFn: ({ pageParam }) => fetchProjects(pageParam),
initialPageParam: 0, // ✅ Required in v5
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
❌ Never use enabled with useSuspenseQuery
// 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
// 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
Why It Happens: v5 removed all function overloads, only object syntax works
Prevention: Always use useQuery({ queryKey, queryFn, ...options })
Before (v4):
useQuery(['todos'], fetchTodos, { staleTime: 5000 })
After (v5):
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
staleTime: 5000
})
Issue #2: Query Callbacks Removed
Error: Callbacks don't run, TypeScript errors
Source: v5 Breaking Changes
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):
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
onSuccess: (data) => {
console.log('Todos loaded:', data)
},
})
After (v5):
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
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):
const { data, isLoading } = useQuery(...)
if (isLoading) return <div>Loading...</div>
After (v5):
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
Why It Happens: Renamed to better reflect "garbage collection time"
Prevention: Use gcTime instead of cacheTime
Before (v4):
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
cacheTime: 1000 * 60 * 60,
})
After (v5):
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
gcTime: 1000 * 60 * 60,
})
Issue #5: useSuspenseQuery + enabled
Error: Type error, enabled option not available
Source: GitHub Discussion #6206
Why It Happens: Suspense guarantees data is available, can't conditionally disable
Prevention: Use conditional rendering instead of enabled option
Before (v4/incorrect):
useSuspenseQuery({
queryKey: ['todo', id],
queryFn: () => fetchTodo(id),
enabled: !!id, // ❌ Not allowed
})
After (v5/correct):
// 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
Why It Happens: v4 passed undefined as first pageParam, v5 requires explicit value
Prevention: Always specify initialPageParam for infinite queries
Before (v4):
useInfiniteQuery({
queryKey: ['projects'],
queryFn: ({ pageParam = 0 }) => fetchProjects(pageParam),
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
After (v5):
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
Why It Happens: Replaced with more flexible placeholderData function
Prevention: Use placeholderData: keepPreviousData helper
Before (v4):
useQuery({
queryKey: ['todos', page],
queryFn: () => fetchTodos(page),
keepPreviousData: true,
})
After (v5):
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
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):
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):
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
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):
// 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):
// 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
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):
// 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):
// 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
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):
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):
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
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):
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+):
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 | Fixed in PR #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):
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+):
// 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
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):
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
},
});
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 53
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
tanstack-query- Source
- github.com/dennislee928/ethic-latex