**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) * * * 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'] }, })
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> ) }
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> }
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) })
online (default)alwaysofflineFirstconst { isPending, fetchStatus } = useQuery(...) // isPending + fetchStatus === 'paused' = offline, waiting for network
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]
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)
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 })
maxPages requires bi-directional pagination (getNextPageParam AND getPreviousPageParam).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> }
// 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
// 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, })
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 })useQuery(['todos'], fetchTodos, { staleTime: 5000 })useQuery({ queryKey: ['todos'], queryFn: fetchTodos, staleTime: 5000 })
useEffect for side effects, or move logic to mutation callbacksuseQuery({ 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])
status: 'loading' renamed to status: 'pending', isLoading meaning changed Prevention: Use isPending for initial load, isLoading for "pending AND fetching"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)
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 cacheTimeuseQuery({ queryKey: ['todos'], queryFn: fetchTodos, cacheTime: 1000 * 60 * 60, }) `**After (v5):**` useQuery({ queryKey: ['todos'], queryFn: fetchTodos, gcTime: 1000 * 60 * 60, })
enabled optionuseSuspenseQuery({ 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> }
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 queriesuseInfiniteQuery({ 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, })
keepPreviousData is not a valid option Source: v5 Migration: placeholderData Why It Happens: Replaced with more flexible placeholderData function Prevention: Use placeholderData: keepPreviousData helperuseQuery({ 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, })
unknown, v5 defaults to Error type Prevention: If throwing non-Error types, specify error type explicitlyconst { 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)
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// 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
getServerSnapshot in useSyncExternalStore.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// 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>; }
getServerSnapshot in useSyncExternalStore.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: falseconst { 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, });
retryOnMount is slightly misleading - it controls whether errored queries trigger a new fetch on mount, not automatic retries.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 3useMutation({ 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 } });
onMutate, the onMutateResult parameter will be undefined. This breaking change was introduced in a patch version.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.8export 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 });
openapi-react-query that produce readonly query keys.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 callbackconst 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):**` 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>
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 productionasync 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:**` // Keep query observed with staleTime const { data } = useQuery({ queryKey: ['data'], queryFn: fetchData, staleTime: Infinity, // Keeps query active });
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// Only active queries (currently being observed) will refetch queryClient.invalidateQueries({ queryKey: ['todos'] }); `**To refetch inactive queries:**` queryClient.invalidateQueries({ queryKey: ['todos'], refetchType: 'all' // Refetch active AND inactive });
Note: These tips come from community experts and maintainer blogs. Verify against your version.
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.// 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:**` // Write options as functions that reference latest values const getStaleTime = () => shouldUseLongCache ? 60000 : 5000; useQuery({ queryKey: ['todos'], queryFn: fetchTodos, staleTime: getStaleTime(), // Evaluated on each render });
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.// ❌ 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:**` // ✅ 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():**` // ✅ Manual refresh of same data (refresh button) const { data, refetch } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos, }); <button onClick={() => refetch()}>Refresh</button> // Same parameters
const { data: posts } = useQuery({ queryKey: ['users', userId, 'posts'], queryFn: () => fetchUserPosts(userId), enabled: !!user, // Wait for 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, // Required in v5 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)
/websites/tanstack_query