TanStack Query is an async state management library for fetching, caching, synchronizing, and updating server state in your web applications. It is protocol‑agnostic (REST, GraphQL, raw promises — anything that returns a Promise) and ships first‑class adapters for React, Vue, Solid, Svelte, Angular, Lit, and Preact.
Where most state libraries help you store data your app owns (client state), TanStack Query specializes in the data your app borrows from a server: data you don't control, that can go stale the moment you fetch it, and that many parts of your UI need at once. It replaces the hand‑written useEffect + useState fetch‑loop‑and‑cache code that every application reinvents, and does it with caching, background updates, request de‑duplication, retries, and pagination built in.
Note
This guide documents version 5.101.0 of the
@tanstack/*packages. It is built directly from the library source, so every option, return field, and method described here exists in the code you install. The package suite shares a single version number across all adapters.
1. Quick Start
The fastest way to feel what TanStack Query does is to replace a manual fetch with useQuery. The example below uses React, the most widely used adapter; the framework table shows the equivalent call for every other framework.
import {
QueryClient,
QueryClientProvider,
useQuery,
} from '@tanstack/react-query'
// 1. Create a client. One per app, created once.
const queryClient = new QueryClient()
// 2. Wrap your app so every component can reach the client.
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<Todos />
</QueryClientProvider>
)
}
// 3. Read server state anywhere with useQuery.
function Todos() {
const { isPending, isError, data, error } = useQuery({
queryKey: ['todos'],
queryFn: async () => {
const res = await fetch('https://api.example.com/todos')
return res.json()
},
})
if (isPending) return <span>Loading…</span>
if (isError) return <span>Error: {error.message}</span>
return (
<ul>
{data.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
)
}
That handful of lines already gives you: a cached result keyed by ['todos'], automatic loading/error state, request de‑duplication if two components ask for ['todos'] at once, a background refetch when the user refocuses the window, and up to three automatic retries on failure.
Tip
Define your
QueryClientoutside the component tree (module scope) or in auseStateinitializer so it is created exactly once. Re‑creating it on every render throws away your entire cache.
2. Installation
Each framework has its own adapter package. Install the one that matches your framework — the core engine (@tanstack/query-core) is pulled in automatically as a dependency, so you never install it directly.
npm
npm install @tanstack/react-query
pnpm
pnpm add @tanstack/react-query
yarn
yarn add @tanstack/react-query
bun
bun add @tanstack/react-query
Swap @tanstack/react-query for your framework's package from the table below.
Requirements
| Requirement | Detail |
|---|---|
| Module format | ESM and CommonJS builds are both shipped; works with Vite, webpack, Next.js, Rollup, esbuild, etc. |
| TypeScript | First‑class types are bundled. TypeScript is optional but recommended. |
React (for react-query) |
Peer dependency react@^18 || ^19 (and react-dom at a matching version). |
| Other frameworks | Each adapter declares its host framework as a peer dependency (Vue 2.6+/3, Solid 1.x, Svelte 5, Angular 16+). |
Important
TanStack Query does not bundle a fetching client. You bring your own —
fetch,axios,graphql-request, a database SDK, or any function that returns aPromise. The library only manages the result of that promise.
3. The five‑framework model
TanStack Query is one product with several published front‑ends. A single framework‑agnostic core (@tanstack/query-core) implements caching, retries, and the network/focus logic. Each adapter is a thin binding that connects that core to a framework's reactivity system. The concepts in this guide — query keys, staleTime, mutations, invalidation — are identical across every adapter. Only the function name and the shape of the returned reactive value differ.
| Framework | Package | Read a query | Run a mutation | Provide the client |
|---|---|---|---|---|
| React | @tanstack/react-query |
useQuery |
useMutation |
<QueryClientProvider> |
| Vue | @tanstack/vue-query |
useQuery |
useMutation |
VueQueryPlugin / <QueryClientProvider> |
| Solid | @tanstack/solid-query |
createQuery |
createMutation |
<QueryClientProvider> |
| Svelte | @tanstack/svelte-query |
createQuery |
createMutation |
<QueryClientProvider> |
| Angular | @tanstack/angular-query-experimental |
injectQuery |
injectMutation |
provideTanStackQuery() |
| Lit | @tanstack/lit-query |
QueryController |
MutationController |
<query-client-provider> |
| Preact | @tanstack/preact-query |
useQuery |
useMutation |
<QueryClientProvider> |
Note
In Solid,
createQueryis literally an alias ofuseQuery(export const createQuery = useQuery), kept to match Solid's naming conventions. Angular's package name carries theexperimentalsuffix, but its API is stable and widely used.
The bulk of this guide uses React syntax. The Using other frameworks section spells out the per‑framework differences. Mentally, every useQuery({ … }) you read below maps directly onto createQuery(() => ({ … })) (Solid/Svelte) or injectQuery(() => ({ … })) (Angular).
4. Core concepts
Three ideas underpin everything else. Understanding them up front makes every option later feel obvious.
Queries
A query is a declarative subscription to an asynchronous source of data, identified by a unique key. You don't call a query; you declare that a component needs the data behind a key, and the library fetches, caches, and keeps it fresh for you.
A query needs two things:
queryKey— a serializable array that uniquely identifies the data, e.g.['todos']or['todo', todoId].queryFn— anasyncfunction (or any function returning aPromise) that fetches and returns the data. It must resolve to a value other thanundefined.
const query = useQuery({
queryKey: ['todos', { status: 'done' }],
queryFn: () => fetchTodos({ status: 'done' }),
})
Query keys
Query keys are how the cache addresses data. They are arrays and are hashed deterministically (object property order does not matter), so:
['todos']and['todos']are the same query.['todo', 5]and['todo', 6]are different queries with separate cache entries.['todos', { type: 'done' }]and['todos', { type: 'done' }]match even if the object was constructed differently.
Tip
Treat the query key like a dependency array: everything your
queryFnuses to fetch should appear in the key. If your fetch depends ontodoId, the key must includetodoId(['todo', todoId]). This guarantees each distinct input gets its own cache entry and refetches automatically when the input changes.
Caution
As of v4, query keys must be arrays. Passing a bare string logs
As of v4, queryKey needs to be an Array. If you are using a string like 'repoData', please change it to an Array, e.g. ['repoData']to the console.
status vs fetchStatus
A query result exposes two orthogonal status fields, and conflating them is the most common source of confusion. Both come straight from the observer in the core.
| Field | Values | Meaning |
|---|---|---|
status |
'pending' | 'error' | 'success' |
Do we have data? pending = no data yet, success = data present, error = the fetch threw. |
fetchStatus |
'fetching' | 'paused' | 'idle' |
Is the queryFn running right now? fetching = in flight, paused = wants to run but is offline, idle = not running. |
They are independent because a query can hold successful data (status: 'success') while simultaneously refetching in the background (fetchStatus: 'fetching'). Convenience booleans are derived from these:
isPending⇔status === 'pending'isSuccess⇔status === 'success'isError⇔status === 'error'isFetching⇔fetchStatus === 'fetching'isLoading⇔isPending && isFetching(the first load, no cached data yet)
Note
Use
isPendingto decide "do I have anything to render?" andisFetchingto decide "should I show a background spinner?". UseisLoadingfor the initial hard‑loading state.
The caching lifecycle: staleTime and gcTime
Two timers govern every cached entry. Getting these right is the single biggest lever on your app's network behavior.
staleTime(default0) — how long fetched data is considered fresh. Fresh data is served from cache with no background refetch. Once it passesstaleTime, the data becomes stale and the library will refetch it in the background on the next trigger (mount, window focus, reconnect).gcTime(default5 minutes;Infinityon the server) — how long an inactive query (one with no mounted observers) is kept in memory before being garbage‑collected. When the last component using a query unmounts, agcTimetimer starts; if nothing re‑subscribes before it fires, the entry is deleted.
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
staleTime: 60_000, // data stays fresh for 1 minute — no refetch in that window
gcTime: 5 * 60_000, // keep the cache 5 minutes after the last observer unmounts
})
Tip
staleTimecontrols freshness (when to refetch);gcTimecontrols memory (when to forget). They are unrelated. A common, well‑behaved default is a non‑zerostaleTime(say 30–60s) to cut redundant refetches, leavinggcTimeat its 5‑minute default.
Note
Set
staleTime: 'static'to mark data that should never be considered stale and never refetched automatically — useful for truly immutable resources. SetstaleTime: Infinityto keep data fresh forever but still allow manual refetches/invalidation.
5. Fetching data with useQuery
useQuery is the workhorse. The minimal call needs only a key and a function; everything else is an option with a sensible default.
function TodoList() {
const { data, error, status, fetchStatus, refetch } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
// Handle the three states
if (status === 'pending') return <Spinner />
if (status === 'error') return <Error message={error.message} />
return (
<>
<button onClick={() => refetch()}>Refresh</button>
{fetchStatus === 'fetching' && <InlineSpinner />}
<ul>{data.map((t) => <li key={t.id}>{t.title}</li>)}</ul>
</>
)
}
The query function context
Your queryFn receives a context object with everything it needs. The most important member is signal, an AbortSignal you can forward to fetch so TanStack Query can cancel in‑flight requests automatically.
useQuery({
queryKey: ['todo', todoId],
queryFn: async ({ queryKey, signal, client, meta }) => {
const [, id] = queryKey // destructure the key
const res = await fetch(`/api/todos/${id}`, { signal }) // forward the abort signal
if (!res.ok) throw new Error('Network response was not ok')
return res.json()
},
})
| Context member | Type | Purpose |
|---|---|---|
queryKey |
the key array | The exact key this query was called with. |
signal |
AbortSignal |
Forward to fetch/axios to enable automatic cancellation. |
client |
QueryClient |
The active client, for imperative reads inside the fetch. |
meta |
Record<string, unknown> | undefined |
Arbitrary payload you attached via the meta option. |
pageParam (infinite only) |
unknown |
The cursor for the page being fetched. |
Warning
Your
queryFnmust return a value and must throw on failure. Returningundefinedis treated as a bug — the library throws<queryHash> data is undefinedand logsQuery data cannot be undefined. Please make sure to return a value other than undefined from your query function.Afetch()call that resolves with a 404 does not reject on its own; checkres.okand throw yourself.
6. Dependent and disabled queries
Sometimes a query should only run once another piece of data exists, or only when the user takes an action. The enabled option controls this.
// Fetch the user first…
const { data: user } = useQuery({
queryKey: ['user', email],
queryFn: () => getUserByEmail(email),
})
const userId = user?.id
// …then fetch their projects only once we have an id.
const { data: projects } = useQuery({
queryKey: ['projects', userId],
queryFn: () => getProjectsByUser(userId),
enabled: !!userId, // query is disabled until userId is truthy
})
enabled accepts a boolean or a function (query) => boolean, so you can compute it from query state. While a query is disabled it sits idle, exposes isPending: true with no data, and will not fetch on mount or focus.
skipToken for type‑safe disabling
If you disable a query by setting enabled: false, TypeScript still thinks queryFn might run. skipToken is a type‑safe alternative: pass it as the queryFn to disable the query while keeping accurate types.
import { skipToken, useQuery } from '@tanstack/react-query'
useQuery({
queryKey: ['todo', todoId],
queryFn: todoId ? () => fetchTodo(todoId) : skipToken,
})
When queryFn === skipToken, the core sets enabled = false automatically.
7. Showing fresh data while loading new data
When the query key changes (paging, filtering, search), the new key is a brand‑new cache entry, so data would normally flip to undefined and your UI would flash a spinner. Two options prevent this.
placeholderData and keepPreviousData
placeholderData supplies temporary data to display while the real fetch is pending. Pass a value, or a function that receives the previous query's data so you can keep showing the last page during pagination. The library ships keepPreviousData exactly for this:
import { keepPreviousData, useQuery } from '@tanstack/react-query'
const { data, isPlaceholderData } = useQuery({
queryKey: ['projects', page],
queryFn: () => fetchProjects(page),
placeholderData: keepPreviousData, // show page N while page N+1 loads
})
While placeholder data is on screen, isPlaceholderData is true — use it to dim controls or disable the "next" button. Placeholder data is never persisted to the cache and is fully replaced once real data arrives.
initialData
initialData seeds the cache with data you already have (e.g. from SSR or a list view), so the query starts in success and may skip the initial fetch entirely. Unlike placeholderData, it is written to the cache and respects staleTime.
useQuery({
queryKey: ['todo', id],
queryFn: () => fetchTodo(id),
initialData: () => queryClient.getQueryData(['todos'])?.find((t) => t.id === id),
initialDataUpdatedAt: () => queryClient.getQueryState(['todos'])?.dataUpdatedAt,
})
| Option | Written to cache? | Counts as real data? | Typical use |
|---|---|---|---|
placeholderData |
No | No (isPlaceholderData: true) |
Smooth pagination, skeleton‑replacement |
initialData |
Yes | Yes (status: 'success') |
SSR seed, prime detail from a list |
8. Transforming results with select
The select option transforms or narrows the data a component receives, without changing what's stored in the cache. It runs on every render where data is present, and its result is memoized via structural sharing, so the component only re‑renders when the selected output actually changes.
// This component re-renders only when the todo COUNT changes,
// not on every change to the todos array.
const todoCount = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: (todos) => todos.length,
})
Tip
selectis the idiomatic way to subscribe to a slice of a large cached object. Multiple components can share one cache entry (['todos']) yet eachselecta different field and re‑render independently.
9. Mutations
Queries read data; mutations create, update, or delete it. Use useMutation for anything with side effects on the server. Unlike queries, mutations are imperative — they don't run until you call mutate.
function AddTodo() {
const mutation = useMutation({
mutationFn: (newTodo) => fetch('/api/todos', {
method: 'POST',
body: JSON.stringify(newTodo),
}).then((r) => r.json()),
})
return (
<>
{mutation.isPending && <span>Saving…</span>}
{mutation.isError && <span>Error: {mutation.error.message}</span>}
{mutation.isSuccess && <span>Saved!</span>}
<button
onClick={() => mutation.mutate({ title: 'Learn TanStack Query' })}
disabled={mutation.isPending}
>
Add Todo
</button>
</>
)
}
A mutation moves through four states in status: idle → pending → success | error, surfaced as isIdle, isPending, isSuccess, isError.
mutate vs mutateAsync
| Method | Returns | Errors | When to use |
|---|---|---|---|
mutate(vars, options?) |
void |
Swallowed (handle via callbacks/isError) |
The common case; fire‑and‑forget from an event handler. |
mutateAsync(vars, options?) |
Promise<TData> |
Rejects — you must try/catch |
When you need to await the result or chain mutations. |
Side‑effect callbacks
Mutations support lifecycle callbacks at two levels: on the useMutation options (run for every call) and on the mutate call itself (run for that one call). They fire in order: onMutate → onSuccess/onError → onSettled.
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: (variables) => {
// Fires before the mutationFn. Return a context value (used for optimistic rollback).
},
onSuccess: (data, variables, context) => {
// Fires on success.
},
onError: (error, variables, context) => {
// Fires on failure.
},
onSettled: (data, error, variables, context) => {
// Always fires last, success or error.
},
})
// Per-call callbacks (in addition to the ones above):
mutation.mutate(todo, {
onSuccess: () => toast('Saved'),
})
Mutation scopes (serializing mutations)
By default mutations run in parallel. Assign a scope.id to serialize mutations that share that scope — only one runs at a time, the rest queue. This is the mechanism behind safe sequential writes to the same resource.
useMutation({
mutationFn: addComment,
scope: { id: 'comments' }, // all 'comments' mutations run one at a time
})
10. Keeping the UI in sync: invalidation & cache updates
After a mutation succeeds, the cached query data is out of date. There are two strategies to reconcile it, both driven by the QueryClient.
Invalidate and refetch (the simple, reliable way)
Mark queries as stale and let TanStack Query refetch the active ones:
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: addTodo,
onSuccess: () => {
// Any query whose key STARTS WITH ['todos'] is invalidated and refetched.
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
invalidateQueries matches by key prefix by default, so ['todos'] invalidates ['todos'], ['todos', 'done'], ['todos', { page: 2 }], and so on. Pass exact: true to match only the precise key.
Update the cache directly (setQueryData)
For instant updates with no extra network round‑trip, write the new data straight into the cache:
// Replace
queryClient.setQueryData(['todo', newTodo.id], newTodo)
// Or update functionally based on the previous value
queryClient.setQueryData(['todos'], (old) => [...(old ?? []), newTodo])
Optimistic updates
Combine onMutate, onError, and onSettled to update the UI before the server responds and roll back if it fails:
useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] }) // stop in-flight refetches
const previous = queryClient.getQueryData(['todos']) // snapshot for rollback
queryClient.setQueryData(['todos'], (old) => // optimistic write
old.map((t) => (t.id === newTodo.id ? newTodo : t)),
)
return { previous } // becomes `context`
},
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context.previous) // roll back
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] }) // reconcile with server
},
})
Important
Always
cancelQueriesinonMutatebefore writing optimistically, and snapshot the previous value soonErrorcan restore it. Reconcile inonSettledso the cache ends up matching the server regardless of outcome.
11. Pagination and infinite scroll
Classic pagination
Put the page number in the key and use placeholderData: keepPreviousData (see §7) so the current page stays visible while the next loads.
const { data, isPlaceholderData } = useQuery({
queryKey: ['projects', page],
queryFn: () => fetchProjects(page),
placeholderData: keepPreviousData,
})
Infinite queries
useInfiniteQuery powers "load more" and infinite‑scroll UIs. It stores pages of data and tracks a cursor (pageParam) for each direction.
import { useInfiniteQuery } from '@tanstack/react-query'
const {
data, // { pages: [...], pageParams: [...] }
fetchNextPage,
fetchPreviousPage,
hasNextPage,
hasPreviousPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['projects'],
queryFn: ({ pageParam }) => fetchProjects(pageParam),
initialPageParam: 0,
getNextPageParam: (lastPage, allPages, lastPageParam) => lastPage.nextCursor ?? undefined,
getPreviousPageParam: (firstPage) => firstPage.prevCursor ?? undefined,
})
return (
<>
{data.pages.map((page) =>
page.items.map((item) => <Row key={item.id} {...item} />),
)}
<button onClick={() => fetchNextPage()} disabled={!hasNextPage || isFetchingNextPage}>
{isFetchingNextPage ? 'Loading…' : hasNextPage ? 'Load more' : 'Nothing more'}
</button>
</>
)
| Infinite‑query option | Purpose |
|---|---|
initialPageParam |
The cursor for the first page. Required. |
getNextPageParam(lastPage, allPages, lastPageParam, allPageParams) |
Return the next cursor, or undefined/null when there is no next page. Required. |
getPreviousPageParam(firstPage, …) |
Return the previous cursor for bidirectional scrolling. Optional. |
maxPages |
Cap the number of pages kept in memory; older pages are dropped as new ones load. |
Note
When
getNextPageParamreturnsundefinedornull,hasNextPagebecomesfalse. The result object also surfacesisFetchNextPageError/isFetchPreviousPageErrorso you can distinguish a failed "load more" from a failed initial load.
12. Parallel and combined queries
Dynamic parallel queries with useQueries
When the number of queries isn't known until render (you can't call hooks in a loop), use useQueries. It accepts an array of query options and returns an array of results.
const results = useQueries({
queries: userIds.map((id) => ({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
})),
})
Pass a combine function to merge all results into a single derived value (memoized, so it only recomputes when an underlying result changes):
const { data, pending } = 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),
}),
})
13. Prefetching
Prefetch data before a component needs it (on hover, on route enter) so it's already in the cache when the component mounts.
// Imperative: fire-and-forget, populates the cache
queryClient.prefetchQuery({ queryKey: ['todo', id], queryFn: () => fetchTodo(id) })
// Imperative: returns the data, refetching only if stale (great for loaders)
const todo = await queryClient.ensureQueryData({ queryKey: ['todo', id], queryFn: () => fetchTodo(id) })
There are also declarative React hooks — usePrefetchQuery and usePrefetchInfiniteQuery — that prefetch during render (typically inside a Suspense boundary's parent) so a child can read the data without a loading flash.
| Method | Returns | Refetches if cached? | Use for |
|---|---|---|---|
prefetchQuery |
Promise<void> |
No (only if missing/stale) | Warming the cache on hover/route enter |
ensureQueryData |
Promise<TData> |
Only if stale (optionally revalidates) | Route loaders that need the data returned |
fetchQuery |
Promise<TData> |
Yes if stale | Imperative fetch outside the component lifecycle |
14. Server‑side rendering and hydration
TanStack Query supports SSR/SSG and React Server Components through dehydration (serialize the cache on the server) and hydration (restore it on the client).
// --- Server ---
import { dehydrate, QueryClient } from '@tanstack/react-query'
const queryClient = new QueryClient()
await queryClient.prefetchQuery({ queryKey: ['todos'], queryFn: fetchTodos })
const dehydratedState = dehydrate(queryClient) // serialize to send to the client
// --- Client ---
import { HydrationBoundary, QueryClientProvider } from '@tanstack/react-query'
<QueryClientProvider client={browserClient}>
<HydrationBoundary state={dehydratedState}>
<App />
</HydrationBoundary>
</QueryClientProvider>
By default dehydrate includes successful queries and paused mutations; HydrationBoundary carefully avoids overwriting fresher data already in the browser cache. Dedicated integration packages exist for Next.js App Router (@tanstack/react-query-next-experimental) and other meta‑frameworks.
Tip
On the server,
gcTimedefaults toInfinityso prefetched data isn't garbage‑collected mid‑render before you dehydrate it.
15. Suspense
If you prefer React Suspense for loading and error boundaries over manual isPending/isError checks, use the suspense hooks. They guarantee data is defined in the render path (the component suspends until it is).
import { useSuspenseQuery } from '@tanstack/react-query'
function Todos() {
const { data } = useSuspenseQuery({ queryKey: ['todos'], queryFn: fetchTodos })
return <ul>{data.map((t) => <li key={t.id}>{t.title}</li>)}</ul> // data is guaranteed defined
}
// Wrap with React's boundaries:
<Suspense fallback={<Spinner />}>
<Todos />
</Suspense>
The suspense family includes useSuspenseQuery, useSuspenseInfiniteQuery, and useSuspenseQueries. Pair them with QueryErrorResetBoundary + an error boundary's onReset to make "try again" work after an error.
Note
In suspense mode,
staleTimeis clamped to a minimum of 1000 ms internally to avoid an immediate refetch when a suspended component remounts. Suspense hooks don't acceptenabled,throwOnError, orplaceholderData— those don't make sense when the component must suspend.
16. Network mode, retries, and offline behavior
Retries
By default a failed queryFn is retried 3 times (0 on the server) with exponential backoff capped at 30 s (min(1000 × 2^attempt, 30000)). Tune it per query:
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
retry: 5, // boolean | number | (failureCount, error) => boolean
retryDelay: (attempt) => Math.min(attempt * 1000, 30_000),
})
Note
Mutations do not retry by default (
retry: 0). Opt in explicitly if your write is idempotent.
Network mode
networkMode decides what happens when the device is offline. With the default 'online', fetches that can't run are paused (fetchStatus: 'paused') and resume automatically on reconnect.
networkMode |
Behavior |
|---|---|
'online' (default) |
Only fetch when online. Offline fetches are paused and resume on reconnect. |
'always' |
Always run the queryFn regardless of connectivity (good for non‑network sources like AsyncStorage). |
'offlineFirst' |
Run once even when offline (lets a Service Worker / HTTP cache answer), then pause retries. The default when a persister is set. |
Window focus & reconnect refetching
Stale queries refetch automatically when the window regains focus and when the network reconnects. Toggle these globally or per query:
new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: true, // boolean | 'always' | (query) => …
refetchOnReconnect: true,
refetchOnMount: true,
},
},
})
Polling
Set refetchInterval to refetch on a timer. Pass a function to make the interval dynamic (e.g. stop polling once a job completes), and enable refetchIntervalInBackground to keep polling while the tab is hidden.
useQuery({
queryKey: ['job', id],
queryFn: () => fetchJob(id),
refetchInterval: (query) => (query.state.data?.done ? false : 2000),
})
17. Developer tools, persistence, and helpers
Devtools
The framework devtools packages (@tanstack/react-query-devtools, @tanstack/vue-query-devtools, etc.) render a floating panel that shows every cached query, its status, its data, and lets you trigger refetches and invalidations by hand. They are invaluable during development.
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
Caution
Mounting the same devtools twice logs
Devtools is already mounted. Render exactly one devtools instance per client.
Persistence
The persistence packages let you save the cache to localStorage, sessionStorage, IndexedDB, or React Native AsyncStorage and restore it on reload, giving an instant‑load, offline‑capable experience.
| Package | Use |
|---|---|
@tanstack/query-persist-client-core |
Core persist/restore engine. |
@tanstack/query-sync-storage-persister |
Persister for synchronous stores (localStorage, sessionStorage). |
@tanstack/query-async-storage-persister |
Persister for async stores (React Native AsyncStorage). |
@tanstack/react-query-persist-client (+ per‑framework variants) |
<PersistQueryClientProvider> that wires it together. |
While the cache is being restored from storage, the useIsRestoring() hook returns true and queries hold off fetching to avoid clobbering restored data.
Type‑safe option helpers
queryOptions, infiniteQueryOptions, and mutationOptions let you define a query/mutation config once, share it across useQuery, prefetchQuery, setQueryData, etc., and get a typed query key (the data and error types are attached to the key). This is the recommended pattern for non‑trivial apps.
import { queryOptions } from '@tanstack/react-query'
const todoOptions = (id: number) =>
queryOptions({ queryKey: ['todo', id], queryFn: () => fetchTodo(id) })
useQuery(todoOptions(5))
queryClient.prefetchQuery(todoOptions(5))
queryClient.getQueryData(todoOptions(5).queryKey) // fully typed
Streaming queries (experimental)
experimental_streamedQuery builds a queryFn from an AsyncIterable, accumulating chunks into the cached array as they arrive — useful for streamed LLM tokens or Server‑Sent Events. The query enters success after the first chunk and stays fetching until the stream ends; refetchMode ('reset' | 'append' | 'replace') controls how a refetch handles existing data.
18. Using other frameworks
The concepts are identical; the binding differs. Below are the per‑framework essentials.
Vue
Vue's adapter exposes the same useQuery / useMutation / useInfiniteQuery composables. Reactive refs (and getters) passed in options are unwrapped, so a query re‑runs when a reactive key changes. Install the client with the plugin:
import { VueQueryPlugin } from '@tanstack/vue-query'
app.use(VueQueryPlugin)
Result fields are returned as Vue refs — access them as data.value, isPending.value, etc. (or unwrap in the template automatically).
Solid
Solid uses createQuery / createInfiniteQuery / createMutation. Options are passed as a function so Solid's fine‑grained reactivity can track them:
import { createQuery } from '@tanstack/solid-query'
const query = createQuery(() => ({ queryKey: ['todos'], queryFn: fetchTodos }))
// access reactively: query.data, query.isPending
Svelte
Svelte uses createQuery / createInfiniteQuery / createMutation, returning a Svelte store you read with the $ prefix:
<script>
import { createQuery } from '@tanstack/svelte-query'
const query = createQuery({ queryKey: ['todos'], queryFn: fetchTodos })
</script>
{#if $query.isPending}Loading…{:else if $query.isError}{$query.error.message}{:else}
<ul>{#each $query.data as todo}<li>{todo.title}</li>{/each}</ul>
{/if}
Angular
The @tanstack/angular-query-experimental package uses injectQuery / injectInfiniteQuery / injectMutation, which return signals. Provide the client with provideTanStackQuery(new QueryClient()) in your app config.
import { injectQuery } from '@tanstack/angular-query-experimental'
todos = injectQuery(() => ({ queryKey: ['todos'], queryFn: fetchTodos }))
// template: @if (todos.isPending()) { … } @else { {{ todos.data() }} }
Lit
@tanstack/lit-query provides reactive controllers (QueryController, MutationController, InfiniteQueryController, etc.) hosted on a LitElement, plus a <query-client-provider> element to supply the client through the DOM.
19. ESLint plugin
@tanstack/eslint-plugin-query catches common mistakes at lint time. Add it to your ESLint config and enable the recommended rules:
| Rule | Catches |
|---|---|
@tanstack/query/exhaustive-deps |
Values used in queryFn that are missing from the queryKey. |
@tanstack/query/no-rest-destructuring |
Rest‑destructuring the result, which defeats render‑optimization tracking. |
@tanstack/query/stable-query-client |
A QueryClient created unstably (e.g. re‑created each render). |
@tanstack/query/no-unstable-deps |
Passing unstable values into dependency positions. |
@tanstack/query/infinite-query-property-order |
Infinite‑query options declared in an order that breaks type inference. |
@tanstack/query/mutation-property-order |
Same, for mutation option order. |
@tanstack/query/no-void-query-fn |
A queryFn that returns void/undefined. |
@tanstack/query/prefer-query-options |
Encourages the queryOptions helper. |
A v4→v5 codemod (@tanstack/query-codemods) is also published to automate breaking‑change migrations.
20. Troubleshooting
Error: No QueryClient set, use QueryClientProvider to set one
- Cause: A
useQuery/useMutationhook ran in a tree that isn't wrapped in<QueryClientProvider>(or the equivalent provider for your framework). - Solution: Render
<QueryClientProvider client={queryClient}>above every component that uses a query. In Angular, callprovideTanStackQuery(new QueryClient()); in Vue,app.use(VueQueryPlugin). - Prevention: Put the provider at the very root of your app, once.
Error: <queryHash> data is undefined
- Cause: Your
queryFnreturnedundefined. The console also showsQuery data cannot be undefined. Please make sure to return a value other than undefined from your query function. - Solution: Always
returna value fromqueryFn. If you only need a side effect, return a sentinel likenull. - Prevention: Enable the
@tanstack/query/no-void-query-fnESLint rule.
Console error: As of v4, queryKey needs to be an Array…
- Cause: You passed a string query key, e.g.
queryKey: 'todos'. - Solution: Wrap it in an array:
queryKey: ['todos'].
Error: Bad argument type. Starting with v5, only the "Object" form is allowed…
- Cause: You called a query hook with the old positional argument form (
useQuery(key, fn, options)). - Solution: Use the single‑object form:
useQuery({ queryKey, queryFn, …options }).
Error: Devtools is already mounted / Devtools is not mounted
- Cause: Mounting two devtools instances, or unmounting one that never mounted.
- Solution: Render exactly one devtools component for the app.
Query refetches "too often" / on every focus
- Cause: Default
staleTimeis0, so data is immediately stale and refetches on the next focus/mount/reconnect. - Solution: Set a non‑zero
staleTime(per query or viadefaultOptions.queries.staleTime). To stop focus refetching specifically, setrefetchOnWindowFocus: false.
Query never fetches / stuck pending
- Cause: The query is disabled (
enabledis falsy orqueryFnisskipToken), or the device is offline andnetworkModeis'online'(fetchStatus: 'paused'). - Solution: Check
enabled, and inspectfetchStatus—pausedmeans it's waiting for the network.
Data flashes to a spinner when paginating
- Cause: Changing the query key creates a new cache entry with no data yet.
- Solution: Add
placeholderData: keepPreviousData.
Mutation error throws an unhandled rejection
- Cause: You used
mutateAsyncwithout acatch. - Solution: Use
mutate(errors are surfaced viaisError/onError), or wrapmutateAsyncintry/catch.
21. FAQ
Q: Is TanStack Query a replacement for Redux / Zustand / Pinia? For server state (data fetched from an API), yes — it removes the need to store that data in a general‑purpose store. For client state (UI toggles, form drafts, selections you own), keep using your client‑state library; the two are complementary.
Q: Do I have to use fetch?
No. queryFn is any function returning a Promise. Use axios, graphql-request, ky, a database SDK, or a WebSocket‑backed promise — anything.
Q: How do components share one query?
By using the same queryKey. Two components calling useQuery({ queryKey: ['todos'], … }) share a single cache entry, a single in‑flight request, and re‑render together when it updates.
Q: What's the difference between staleTime and gcTime?
staleTime is how long data stays fresh (controls refetching). gcTime is how long an unused cache entry is kept in memory (controls cleanup). See §4.
Q: Why does my query refetch when I switch back to the tab?
That's refetchOnWindowFocus (default true) refetching stale data. Raise staleTime or set it to false.
Q: How do I cancel a request?
Forward the signal from the query context to fetch. The library aborts it automatically when the query is no longer needed, or when you call queryClient.cancelQueries().
Q: Can I read or write the cache outside a component?
Yes — use the QueryClient directly: queryClient.getQueryData(key), setQueryData(key, updater), invalidateQueries(filters). Note getQueryData is a snapshot and does not subscribe to updates; use useQuery inside components.
Q: Why must queryFn throw on error?
TanStack Query decides status: 'error' by catching a rejected promise. A fetch() to a 404 resolves successfully at the HTTP layer, so you must check res.ok and throw yourself.
Q: Do mutations retry like queries?
No. Mutations default to retry: 0. Enable retries only for idempotent writes.
22. Reference
Common useQuery options
| Option | Type | Default | Purpose |
|---|---|---|---|
queryKey |
unknown[] |
— (required) | Unique, hashed cache key. |
queryFn |
(ctx) => Promise<T> | skipToken |
— | Fetches and returns the data. |
enabled |
boolean | (query) => boolean |
true |
Turn automatic fetching on/off. |
staleTime |
number | 'static' | (query) => … |
0 |
How long data stays fresh. |
gcTime |
number |
300000 (5 min) |
How long inactive cache is kept. |
retry |
boolean | number | fn |
3 (0 on server) |
Retry policy on failure. |
retryDelay |
number | (attempt, err) => number |
exp. backoff, max 30 s | Delay between retries. |
networkMode |
'online'|'always'|'offlineFirst' |
'online' |
Offline fetch behavior. |
refetchOnWindowFocus |
boolean|'always'|fn |
true |
Refetch stale data on focus. |
refetchOnReconnect |
boolean|'always'|fn |
true* |
Refetch stale data on reconnect. |
refetchOnMount |
boolean|'always'|fn |
true |
Refetch stale data on mount. |
refetchInterval |
number|false|fn |
false |
Polling interval (ms). |
refetchIntervalInBackground |
boolean |
false |
Keep polling when tab is hidden. |
select |
(data) => U |
— | Transform/narrow returned data. |
placeholderData |
value | (prev, prevQuery) => value |
— | Temporary, non‑cached data. |
initialData |
value | () => value |
— | Seed the cache; counts as real data. |
initialDataUpdatedAt |
number | () => number |
— | Timestamp for initialData freshness. |
structuralSharing |
boolean | fn |
true |
Reuse references across updates. |
notifyOnChangeProps |
string[] | 'all' | fn |
tracked | Limit which fields trigger re‑render. |
throwOnError |
boolean | (err, query) => boolean |
false (or suspense) |
Throw to an error boundary. |
retryOnMount |
boolean | fn |
true |
Retry an errored query on remount. |
meta |
Record<string, unknown> |
— | Arbitrary payload available in queryFn. |
queryKeyHashFn |
(key) => string |
built‑in hash | Custom key hashing. |
maxPages (infinite) |
number |
— | Cap pages kept in memory. |
* refetchOnReconnect defaults to false when networkMode is 'always'.
Common useQuery result fields
| Field | Type | Meaning |
|---|---|---|
data |
T | undefined |
Last resolved data. |
error |
TError | null |
Last error, if any. |
status |
'pending'|'error'|'success' |
Do we have data? |
fetchStatus |
'fetching'|'paused'|'idle' |
Is the queryFn running? |
isPending / isSuccess / isError |
boolean |
Derived from status. |
isLoading |
boolean |
First load (isPending && isFetching). |
isFetching / isRefetching |
boolean |
Any fetch / background refetch in flight. |
isStale |
boolean |
Data is invalidated or past staleTime. |
isPlaceholderData |
boolean |
Showing placeholderData. |
isPaused |
boolean |
Wants to fetch but offline. |
dataUpdatedAt / errorUpdatedAt |
number |
Timestamps. |
failureCount / failureReason |
number / TError|null |
Retry diagnostics. |
refetch |
(opts?) => Promise<…> |
Manually refetch. |
Key QueryClient methods
| Method | Purpose |
|---|---|
getQueryData(key) / setQueryData(key, updater) |
Read / write cache imperatively. |
getQueriesData(filters) / setQueriesData(filters, updater) |
Bulk read / write by filter. |
invalidateQueries(filters) |
Mark stale + refetch active matches. |
refetchQueries(filters) |
Refetch matches now. |
cancelQueries(filters) |
Cancel in‑flight fetches (for optimistic updates). |
removeQueries(filters) / resetQueries(filters) |
Remove / reset cache entries. |
fetchQuery / prefetchQuery |
Imperative fetch / cache warm‑up. |
ensureQueryData |
Return cached data, fetching only if missing. |
fetchInfiniteQuery / prefetchInfiniteQuery / ensureInfiniteQueryData |
Infinite‑query equivalents. |
setQueryDefaults(key, opts) / setMutationDefaults(key, opts) |
Per‑key default options. |
setDefaultOptions(opts) / getDefaultOptions() |
Global defaults. |
isFetching(filters) / isMutating(filters) |
Count in‑flight queries / mutations. |
resumePausedMutations() |
Resume mutations paused while offline. |
clear() |
Empty the entire cache. |
Query filters
Many QueryClient methods and the useIsFetching hook accept a filters object to target a subset of queries:
| Filter | Values | Meaning |
|---|---|---|
queryKey |
unknown[] |
Match keys (prefix by default). |
exact |
boolean |
Require an exact key match. |
type |
'active'|'inactive'|'all' |
Match by observer activity. |
stale |
boolean |
Match only stale / fresh queries. |
fetchStatus |
'fetching'|'paused'|'idle' |
Match by fetch state. |
predicate |
(query) => boolean |
Arbitrary custom matcher. |