| 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:
import { useMutationState } from '@tanstack/react-query'
function GlobalLoadingIndicator() {
const pendingMutations = useMutationState({
filters: { status: 'pending' },
select: (mutation) => mutation.state.variables,
})
if (pendingMutations.length === 0) return null
return <div>Saving {pendingMutations.length} items...</div>
}
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'] })
},
})
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,
})
return <div>{data.map(...)}</div>
}
Network Mode (Offline/PWA Support)
Control behavior when offline:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
networkMode: 'offlineFirst',
},
},
})
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
networkMode: 'always',
})
| 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(...)
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,
}),
})
if (results.pending) return <Loading />
console.log(results.data)
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,
})
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,
maxPages: 3,
})
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
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5,
gcTime: 1000 * 60 * 60,
refetchOnWindowFocus: false,
},
},
})
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
Step 3: Query + Mutation Hooks
import { useQuery, useMutation, useQueryClient, queryOptions } from '@tanstack/react-query'
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'] })
},
})
}
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
useQuery({ queryKey, queryFn, ...options })
useMutation({ mutationFn, ...options })
✅ Use array query keys
queryKey: ['todos']
queryKey: ['todos', id]
queryKey: ['todos', { filter }]
✅ Configure staleTime appropriately
staleTime: 1000 * 60 * 5
✅ Use isPending for initial loading state
if (isPending) return <Loading />
✅ 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
Never Do
❌ Never use v4 array/function syntax
useQuery(['todos'], fetchTodos, options)
useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
❌ Never use query callbacks (onSuccess, onError, onSettled in queries)
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
onSuccess: (data) => {},
})
const { data } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
useEffect(() => {
if (data) {
}
}, [data])
useMutation({
mutationFn: addTodo,
onSuccess: () => {},
})
❌ Never use deprecated options
cacheTime: 1000
isLoading: true
keepPreviousData: true
onSuccess: () => {}
useErrorBoundary: true
❌ Never assume isLoading means "no data yet"
isLoading = isPending && isFetching
isPending = no data yet
❌ Never forget initialPageParam for infinite queries
useInfiniteQuery({
queryKey: ['projects'],
queryFn: ({ pageParam }) => fetchProjects(pageParam),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
❌ Never use enabled with useSuspenseQuery
useSuspenseQuery({
queryKey: ['todo', id],
queryFn: () => fetchTodo(id),
enabled: !!id,
})
{id && <TodoComponent id={id} />}
❌ Never rely on refetchOnMount: false for errored queries
useQuery({
queryKey: ['data'],
queryFn: failingFetch,
refetchOnMount: false,
})
useQuery({
queryKey: ['data'],
queryFn: failingFetch,
refetchOnMount: false,
retryOnMount: false,
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>
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,
})
After (v5/correct):
{id ? (
<TodoComponent id={id} />
) : (
<div>No ID selected</div>
)}
function TodoComponent({ id }: { id: number }) {
const { data } = useSuspenseQuery({
queryKey: ['todo', id],
queryFn: () => fetchTodo(id),
})
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,
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
},
})
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
},
})
const { error } = useQuery({
queryKey: ['data'],
queryFn: async () => {
if (Math.random() > 0.5) throw new Error('custom error')
return data
},
})
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):
streamingQueryClient.prefetchQuery({ queryKey: ['data'], queryFn: getData });
const { data, isFetching } = useSuspenseQuery({ queryKey: ['data'], queryFn: getData });
return <>{data && <div>{data}</div>} {isFetching && <Loading />}</>;
After (workaround):
await streamingQueryClient.prefetchQuery({ queryKey: ['data'], queryFn: getData });
const { data } = useSuspenseQuery({ queryKey: ['data'], queryFn: getData });
return <div>{data}</div>;
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):
const queryClient = getServerQueryClient();
await queryClient.prefetchQuery({ queryKey: ['todos'], queryFn: fetchTodos });
function Todos() {
const { data, isLoading } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos });
if (isLoading) return <div>Loading...</div>;
return <div>{data.length} todos</div>;
}
After (workaround):
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,
retry: 0,
});
After (correct):
const { data, error } = useQuery({
queryKey: ['data'],
queryFn: failingFetch,
refetchOnMount: false,
retryOnMount: false,
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) => {
},
onSuccess: (data, variables, context) => {
}
});
After (v5.89.0+):
useMutation({
mutationFn: addTodo,
onError: (error, variables, onMutateResult, context) => {
},
onSuccess: (data, variables, onMutateResult, context) => {
}
});
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;
}
useMutation({
mutationFn: addTodo,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: todoQueryKey('123')
});
}
});
After (v5.90.9+):
queryClient.invalidateQueries({
queryKey: todoQueryKey('123')
});
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;
},
});
After (with explicit cast):
const pendingTodos = useMutationState({
filters: { mutationKey: ['addTodo'], status: 'pending' },
select: (mutation) => mutation.state.variables as Todo,
});
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
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:
async function loadData() {
try {
const data = await queryClient.fetchQuery({
queryKey: ['data'],
queryFn: fetchData,
});
console.log('Loaded:', data);
} catch (error) {
console.error('Failed:', error);
}
}
function Component() {
const { data } = useQuery({ queryKey: ['data'], queryFn: fetchData });
}
Workaround:
const { data } = useQuery({
queryKey: ['data'],
queryFn: fetchData,
staleTime: Infinity,
});
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
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:
queryClient.invalidateQueries({ queryKey: ['todos'] });
To refetch inactive queries:
queryClient.invalidateQueries({
queryKey: ['todos'],
refetchType: 'all'
});
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 | 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:
function ComponentA() {
const { data } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
staleTime: 5000,
});
}
function ComponentB() {
const { data } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
staleTime: 60000,
});
}
Recommended approach:
const getStaleTime = () => shouldUseLongCache ? 60000 : 5000;
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
staleTime: getStaleTime(),
});
Tip: refetch() is NOT for Changed Parameters
Source: Avoiding Common Mistakes with TanStack Query | 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:
const [page, setPage] = useState(1);
const { data, refetch } = useQuery({
queryKey: ['todos'],
queryFn: () => fetchTodos(page),
});
<button onClick={() => { setPage(2); refetch(); }}>Next</button>
Correct pattern:
const [page, setPage] = useState(1);
const { data } = useQuery({
queryKey: ['todos', page],
queryFn: () => fetchTodos(page),
});
<button onClick={() => setPage(2)}>Next</button>
When to use refetch():
const { data, refetch } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
<button onClick={() => refetch()}>Refresh</button>
Key Patterns
Dependent Queries (Query B waits for Query A):
const { data: posts } = useQuery({
queryKey: ['users', userId, 'posts'],
queryFn: () => fetchUserPosts(userId),
enabled: !!user,
})
Parallel Queries (fetch multiple at once):
const results = useQueries({
queries: ids.map(id => ({ queryKey: ['todos', id], queryFn: () => fetchTodo(id) })),
})
Prefetching (preload on hover):
queryClient.prefetchQuery({ queryKey: ['todo', id], queryFn: () => fetchTodo(id) })
Infinite Scroll (useInfiniteQuery):
useInfiniteQuery({
queryKey: ['todos', 'infinite'],
queryFn: ({ pageParam }) => fetchTodosPage(pageParam),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
Query Cancellation (auto-cancel on queryKey change):
queryFn: async ({ signal }) => {
const res = await fetch(`/api/todos?q=${search}`, { signal })
return res.json()
}
Data Transformation (select):
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