Jesse A. Green / notebook

TanStack Query — User Guide

Generated June 2026 from TanStack/query at 3270e09

AI-generated documentation. The library is checked by sampling (three repositories so far); this guide was not individually reviewed. Verify against the project before relying on it.

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 QueryClient outside the component tree (module scope) or in a useState initializer 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 a Promise. 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, createQuery is literally an alias of useQuery (export const createQuery = useQuery), kept to match Solid's naming conventions. Angular's package name carries the experimental suffix, 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 — an async function (or any function returning a Promise) that fetches and returns the data. It must resolve to a value other than undefined.
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 queryFn uses to fetch should appear in the key. If your fetch depends on todoId, the key must include todoId (['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 isPending to decide "do I have anything to render?" and isFetching to decide "should I show a background spinner?". Use isLoading for 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 (default 0) — how long fetched data is considered fresh. Fresh data is served from cache with no background refetch. Once it passes staleTime, the data becomes stale and the library will refetch it in the background on the next trigger (mount, window focus, reconnect).
  • gcTime (default 5 minutes; Infinity on 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, a gcTime timer 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

staleTime controls freshness (when to refetch); gcTime controls memory (when to forget). They are unrelated. A common, well‑behaved default is a non‑zero staleTime (say 30–60s) to cut redundant refetches, leaving gcTime at 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. Set staleTime: Infinity to 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 queryFn must return a value and must throw on failure. Returning undefined is treated as a bug — the library throws <queryHash> data is undefined and logs Query data cannot be undefined. Please make sure to return a value other than undefined from your query function. A fetch() call that resolves with a 404 does not reject on its own; check res.ok and 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

select is the idiomatic way to subscribe to a slice of a large cached object. Multiple components can share one cache entry (['todos']) yet each select a 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 cancelQueries in onMutate before writing optimistically, and snapshot the previous value so onError can restore it. Reconcile in onSettled so 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 getNextPageParam returns undefined or null, hasNextPage becomes false. The result object also surfaces isFetchNextPageError / isFetchPreviousPageError so 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, gcTime defaults to Infinity so 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, staleTime is clamped to a minimum of 1000 ms internally to avoid an immediate refetch when a suspended component remounts. Suspense hooks don't accept enabled, throwOnError, or placeholderData — 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/useMutation hook 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, call provideTanStackQuery(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 queryFn returned undefined. The console also shows Query data cannot be undefined. Please make sure to return a value other than undefined from your query function.
  • Solution: Always return a value from queryFn. If you only need a side effect, return a sentinel like null.
  • Prevention: Enable the @tanstack/query/no-void-query-fn ESLint 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 staleTime is 0, so data is immediately stale and refetches on the next focus/mount/reconnect.
  • Solution: Set a non‑zero staleTime (per query or via defaultOptions.queries.staleTime). To stop focus refetching specifically, set refetchOnWindowFocus: false.

Query never fetches / stuck pending

  • Cause: The query is disabled (enabled is falsy or queryFn is skipToken), or the device is offline and networkMode is 'online' (fetchStatus: 'paused').
  • Solution: Check enabled, and inspect fetchStatus — paused means 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 mutateAsync without a catch.
  • Solution: Use mutate (errors are surfaced via isError/onError), or wrap mutateAsync in try/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.