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:
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)
gcT