Jesse A. Green / notebook

TanStack Query — Admin & Architecture 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.

This guide is the deep reference for engineers, architects, library maintainers, and contributors. It documents how TanStack Query is built internally: the framework‑agnostic core engine, the per‑framework adapter layer, the caching and state‑machine internals, the monorepo build pipeline, and the security and operational characteristics of the library. Where the User Guide teaches how to use the library, this guide explains how it works and how to maintain it.


1. Executive summary

TanStack Query is a headless, framework‑agnostic async‑state management library distributed as a 27‑package pnpm monorepo. A single core package (@tanstack/query-core) contains all of the caching, retry, network, and observation logic and has zero runtime framework dependency. Each supported framework ships a thin adapter package that binds the core's imperative observer API to that framework's reactivity primitives (React hooks, Vue refs, Solid signals, Svelte stores, Angular signals, Lit controllers).

Attribute Value
Repository TanStack/query (analyzed at commit 3270e09)
Package version (all packages) 5.101.0
Published packages 27 (core, 7 framework adapters, devtools, persistence, eslint plugin, codemods, utilities)
Language TypeScript (strict), ESM + CJS dual output
Core dependencies None at runtime — the core is self‑contained
Package manager pnpm 11 (workspaces)
Build orchestration Nx (affected graph) + tsup per package
License MIT

The architectural thesis is "one engine, many bindings." All behavior lives in query-core; adapters are deliberately minimal. This is why every concept (query keys, staleTime, mutations, invalidation) behaves identically across frameworks, and why a bug fix or feature in the core lands for all frameworks at once.

                         ┌──────────────────────────────────────────────┐
                         │            @tanstack/query-core                │
                         │  (framework-agnostic engine — no UI deps)      │
                         │                                                │
   App code  ──────►     │  QueryClient ── QueryCache ── Query            │
   (queryFn,             │      │              │           │ (state       │
    mutationFn)          │      │              │           │  machine)    │
                         │      │          MutationCache ─ Mutation        │
                         │      │                                          │
                         │  Observers: QueryObserver / InfiniteQuery /    │
                         │  QueriesObserver / MutationObserver            │
                         │                                                │
                         │  Singletons: focusManager · onlineManager ·    │
                         │  notifyManager · timeoutManager                │
                         └───────────────▲────────────────────────────────┘
                                         │ subscribe() / getCurrentResult()
            ┌────────────┬───────────────┼───────────────┬──────────────┐
        react-query   vue-query     solid-query     svelte-query   angular-query
        (hooks +      (composables  (createX +      (stores +      (injectX +
        useSync-       + refs)       signals)        $-stores)      signals)
        ExternalStore)

2. Repository architecture

Layered package inventory

The 27 packages fall into clear responsibility layers. Organizing them by layer (not alphabetically) is the only way to navigate the repo sensibly.

Layer 0 — The engine

Package Responsibility
@tanstack/query-core The entire caching/retry/observer engine. Everything else depends on it.

Layer 1 — Framework adapters (one per host framework)

Package Host Binding mechanism
@tanstack/react-query React 18/19 useSyncExternalStore over observers
@tanstack/vue-query Vue 2.6+/3 Reactive refs, plugin install
@tanstack/solid-query Solid Fine‑grained signals; options passed as a function
@tanstack/svelte-query Svelte 5 Svelte stores / runes
@tanstack/angular-query-experimental Angular 16+ Signals + DI provider
@tanstack/lit-query Lit Reactive controllers on LitElement
@tanstack/preact-query Preact Hooks (mirror of react-query)

Layer 2 — Devtools (per framework)

@tanstack/query-devtools is the framework‑agnostic devtools UI (a Solid‑built component, the largest single module at ~164 KB). Per‑framework wrappers mount it: @tanstack/react-query-devtools, @tanstack/vue-query-devtools, @tanstack/solid-query-devtools, @tanstack/svelte-query-devtools, @tanstack/preact-query-devtools.

Layer 3 — Persistence

Package Responsibility
@tanstack/query-persist-client-core Core persist/restore engine (persistQueryClient, createPersister, retry strategies).
@tanstack/query-sync-storage-persister Persister for sync stores (localStorage, sessionStorage).
@tanstack/query-async-storage-persister Persister for async stores (RN AsyncStorage), with asyncThrottle.
@tanstack/react-query-persist-client (+ solid/svelte/angular/preact variants) PersistQueryClientProvider wiring.

Layer 4 — Experimental / integration

Package Responsibility
@tanstack/react-query-next-experimental Next.js App Router streaming SSR integration.
@tanstack/query-broadcast-client-experimental Sync cache across browser tabs via BroadcastChannel.

Layer 5 — Tooling

Package Responsibility
@tanstack/eslint-plugin-query Lint rules (exhaustive‑deps, stable‑client, property‑order, etc.).
@tanstack/query-codemods jscodeshift codemods for v4→v5 (and v3→v4) migrations.
@tanstack/query-test-utils Internal test helpers.

Note

examples/ (per framework) and integrations/ (bundler/SSR smoke tests like react-webpack-5, react-next-15, angular-cli-20) are workspace members but are excluded from the publish/build pipeline — they exist only to validate the packages against real toolchains.

The query-core module map

Inside packages/query-core/src/, the engine decomposes into focused modules:

Module Class / export Role
queryClient.ts QueryClient Public facade. Owns the two caches and default options; exposes the imperative API.
queryCache.ts QueryCache A Map<queryHash, Query> with add/remove/find/notify.
query.ts Query A single cache entry. Holds state, runs the fetch, reduces actions.
queryObserver.ts QueryObserver Bridges a Query to a consumer; computes the result object, manages stale/refetch timers.
infiniteQueryObserver.ts InfiniteQueryObserver QueryObserver subclass adding page navigation.
infiniteQueryBehavior.ts infiniteQueryBehavior The page‑fetching algorithm injected into a query's fetch.
queriesObserver.ts QueriesObserver Manages a dynamic array of QueryObservers and the combine reducer.
mutationCache.ts / mutation.ts / mutationObserver.ts MutationCache / Mutation / MutationObserver The mutation counterparts.
retryer.ts createRetryer, CancelledError Retry/backoff loop, pause/continue, cancellation.
removable.ts Removable (abstract) Garbage‑collection timer base class for Query and Mutation.
subscribable.ts Subscribable Observer‑pattern base class for caches, managers, and observers.
focusManager.ts / onlineManager.ts singletons Window‑focus and online/offline detection.
notifyManager.ts notifyManager Batches and schedules listener notifications.
timeoutManager.ts timeoutManager Pluggable setTimeout/setInterval backend for stale/gc timers.
environmentManager.ts environmentManager Server‑vs‑client detection (overridable).
hydration.ts dehydrate / hydrate SSR serialization of cache state.
streamedQuery.ts experimental_streamedQuery Build a queryFn from an AsyncIterable.
thenable.ts pendingThenable React‑compatible promise with .status/.value/.reason.
utils.ts hashing, matching, skipToken, replaceEqualDeep Shared utilities.

3. Core internals

The observer pattern foundation

Everything reactive in the core extends a tiny Subscribable<TListener> base class. It owns a Set of listeners and exposes subscribe(listener) returning an unsubscribe function, plus protected onSubscribe/onUnsubscribe hooks. QueryCache, MutationCache, FocusManager, OnlineManager, and every observer extend it. This single abstraction is why the entire system is push‑based: a state change cascades through notify() calls to subscribed observers, which recompute and notify their listeners (the framework adapter), which trigger a re‑render.

Query.#dispatch(action)
   └─► reducer updates Query.state
        └─► notifyManager.batch(...)
             ├─► each QueryObserver.onQueryUpdate() → updateResult() → notify listener
             └─► QueryCache.notify({ type: 'updated', ... }) → cache subscribers (devtools, etc.)

QueryClient — the facade

QueryClient is what application code instantiates. It owns a QueryCache, a MutationCache, the global defaultOptions, and two maps of per‑key defaults (queryDefaults, mutationDefaults). Its surface splits into:

  • Imperative cache access: getQueryData, setQueryData, getQueriesData, setQueriesData, getQueryState.
  • Cache control: invalidateQueries, refetchQueries, cancelQueries, removeQueries, resetQueries.
  • Fetching: fetchQuery, prefetchQuery, ensureQueryData, and the *InfiniteQuery variants.
  • Defaults: setDefaultOptions, setQueryDefaults, setMutationDefaults, and the defaultQueryOptions/defaultMutationOptions resolvers that merge global → per‑key → call‑site options.
  • Lifecycle: mount/unmount (reference‑counted; the first mount subscribes to focus/online managers, the last unmount tears them down).

mount() is reference counted via #mountCount: only the first provider subscribes the client to focusManager and onlineManager; only the last unmount() unsubscribes. This is what makes "refetch on focus" and "resume on reconnect" work, and why these listeners are not attached when no provider is mounted.

The Query state machine

A Query (one per unique queryHash) holds a QueryState and mutates it only through a reducer driven by dispatched actions — a Redux‑style state machine.

QueryState fields: data, dataUpdateCount, dataUpdatedAt, error, errorUpdateCount, errorUpdatedAt, fetchFailureCount, fetchFailureReason, fetchMeta, isInvalidated, status (pending/error/success), fetchStatus (fetching/paused/idle).

Reducer actions:

Action Effect
fetch Enter fetching (or paused if offline); set pending if no data.
success Store data, bump dataUpdateCount, return to idle.
error Store error, bump counts, set status: 'error', mark isInvalidated.
failed Record a retry failure (fetchFailureCount, fetchFailureReason).
pause / continue Toggle fetchStatus between paused and fetching.
invalidate Set isInvalidated: true (makes the query stale).
setState Merge an arbitrary partial state (used by setQueryData, hydration).

The fetch() method builds an AbortController, lazily exposes its signal to the queryFn (reading signal flips an internal #abortSignalConsumed flag so the core knows whether to abort on unmount), runs the (optional) behavior.onFetch hook (this is where infinite‑query page logic is injected), then drives a retryer. On success it validates the data is not undefined — otherwise it throws <queryHash> data is undefined.

QueryObserver — the result engine

A QueryObserver is the bridge between a Query and one consumer. useQuery (and the equivalents) create exactly one observer. The observer:

  1. Subscribes to its Query and, on onSubscribe, decides whether to fetch on mount (shouldFetchOnMount).
  2. Computes the result object in createResult() — deriving isPending/isLoading/isStale/etc. from query state, applying placeholderData, running select (memoized via reference equality + structural sharing), and tracking select errors.
  3. Manages two timers via timeoutManager: the stale timeout (re‑evaluates isStale when staleTime elapses) and the refetch interval (polling).
  4. Implements render optimization through trackResult(), which wraps the result in a Proxy. Only fields the component actually reads are tracked, and the observer re‑notifies only when a tracked field changes (notifyOnChangeProps). This is why accessing data but not dataUpdatedAt avoids re‑renders on timestamp churn.

InfiniteQueryObserver extends this, overriding createResult to add fetchNextPage/fetchPreviousPage, hasNextPage/hasPreviousPage, and the directional isFetchingNextPage/isFetchNextPageError flags.

Mutations

Mutations mirror queries but are imperative and short‑lived. A MutationObserver (created by useMutation) builds a fresh Mutation each time mutate() is called. The Mutation.execute() method orchestrates the full callback waterfall in strict order: mutationCache.onMutate → options.onMutate → run mutationFn via the retryer → onSuccess → onSettled (or onError → onSettled on failure), dispatching pending/success/error/pause/continue actions to its own reducer.

MutationCache additionally tracks scopes: mutations sharing a scope.id are serialized via canRun()/runNext() so only one executes at a time and paused ones resume in order.

The manager singletons

Four module‑level singletons coordinate cross‑cutting concerns:

Singleton Detects / does Customizable via
focusManager Window focus via visibilitychange. Drives focus refetch. setEventListener, setFocused
onlineManager Online/offline via online/offline events. Drives reconnect refetch + mutation resume. setEventListener, setOnline
notifyManager Batches listener callbacks into a single tick; integrates with React batching. setNotifyFunction, setBatchNotifyFunction, setScheduler
timeoutManager Backs all setTimeout/setInterval for stale/gc/refetch timers. setTimeoutProvider

Note

The focusManager and onlineManager guard typeof window !== 'undefined' so they no‑op in React Native and SSR. Both are overridable, which is how React Native apps wire focus/online detection to AppState/NetInfo.

Garbage collection (Removable)

Query and Mutation both extend the abstract Removable, which manages a gcTime timer. When a query loses its last observer (or a mutation settles with no observers), scheduleGc() starts a timer; if nothing re‑subscribes before it fires, optionalRemove() deletes the entry from its cache. updateGcTime() always keeps the longest requested gcTime and defaults to 5 minutes on the client / Infinity on the server.

The retryer

createRetryer() is a self‑contained loop used by both queries and mutations. It:

  • Resolves the retry policy (boolean | number | (failureCount, error) => boolean; default 3 on client, 0 on server) and retryDelay (default exponential backoff min(1000 × 2^n, 30000)).
  • Pauses between retries when the document is hidden or the device is offline (canContinue()), resuming when focus/connectivity returns.
  • Supports cancellation via CancelledError (with revert/silent flags) — the public isCancelledError helper is deprecated in favor of instanceof CancelledError.
  • Returns a thenable plus cancel/continue/canStart/start/status controls.

Hydration & SSR serialization

dehydrate(client, options) walks both caches and serializes successful queries (defaultShouldDehydrateQuery) and paused mutations (defaultShouldDehydrateMutation) into a plain { queries, mutations } object. hydrate(client, state) rebuilds cache entries, carefully not overwriting entries that already hold newer data (compares dataUpdatedAt/dehydratedAt).


4. Configuration reference

TanStack Query has no external configuration file — it is a library, configured entirely in code through the QueryClient constructor and per‑query/mutation options. (The only process.env read in the published core is NODE_ENV, used to gate development‑only warnings and error redaction; it is not user configuration.)

QueryClientConfig

new QueryClient({
  queryCache?: QueryCache,        // supply a custom cache (rare; for global onError/onSuccess)
  mutationCache?: MutationCache,  // same for mutations
  defaultOptions?: {
    queries?: QueryObserverOptions,    // defaults for every query
    mutations?: MutationObserverOptions,
    dehydrate?: DehydrateOptions,      // SSR serialization defaults
    hydrate?: HydrateOptions,
  },
})

Global default options (defaultOptions.queries)

These are the cross‑cutting knobs most apps set once. See the User Guide reference for the full option table; the operationally significant ones:

Option Default Operational note
staleTime 0 The single biggest lever on request volume. Raise it app‑wide to cut redundant refetches.
gcTime 300000 (5 min) Memory footprint of the cache. Infinity on server to survive SSR render.
retry 3 (queries), 0 (mutations) Set 0 for fail‑fast UIs; a function for status‑code‑aware retries.
networkMode 'online' Set 'always' for non‑HTTP sources; auto‑'offlineFirst' when a persister is set.
refetchOnWindowFocus / refetchOnReconnect / refetchOnMount true Disable globally for low‑traffic or cost‑sensitive backends.
structuralSharing true Keeps stable references across refetches; disable only for very large payloads where the diff cost matters.

Per‑key defaults

setQueryDefaults(queryKey, options) and setMutationDefaults(mutationKey, options) apply defaults to all queries/mutations whose key prefix‑matches. This is the idiomatic way to, e.g., give all ['user', …] queries a longer staleTime without touching call sites.

Customizable internals (advanced)

For specialized environments, the manager singletons accept overrides:

import { focusManager, onlineManager, notifyManager, timeoutManager } from '@tanstack/query-core'

onlineManager.setEventListener((setOnline) => { /* wire to NetInfo */ return cleanup })
notifyManager.setScheduler(requestAnimationFrame)   // custom flush scheduling
timeoutManager.setTimeoutProvider(myCoalescingProvider) // for apps with thousands of timers

5. Build system & development workflow

Toolchain

Concern Tool
Package manager pnpm 11 with workspaces (pnpm-workspace.yaml)
Task orchestration Nx — nx affected/nx run-many build only what changed in the dependency graph
Per‑package bundling tsup (esbuild‑based), emitting ESM + CJS + .d.ts
Type checking tsc, tested against a matrix of TypeScript 5.4 → 6.0 (devDeps typescript54…typescript60)
Unit tests Vitest (jsdom env, istanbul coverage)
Bundle size budget size-limit (@size-limit/preset-small-lib)
Dead‑code/dep hygiene knip
Monorepo dep consistency sherif (catches version drift across packages)
Package publish validation publint + @arethetypeswrong/cli
Linting ESLint (@tanstack/eslint-config, react‑hooks, cspell)
Formatting Prettier
Docs TypeDoc (@tanstack/typedoc-config) + a link verifier (scripts/verify-links.ts)
Releases Changesets

Key scripts (root package.json)

pnpm build          # nx affected --target=build (excludes examples/integrations)
pnpm build:all      # build every package
pnpm dev            # watch + rebuild all
pnpm test           # = test:ci
pnpm test:ci        # nx run-many: sherif, knip, docs, eslint, lib, types, build
pnpm test:lib       # vitest unit tests across affected packages
pnpm test:types     # type-level tests
pnpm test:size      # size-limit budget check
pnpm changeset      # author a changeset for the next release

Tip

The CI gate (test:ci) runs eight target types: sherif (dep consistency), knip (dead code), docs (link integrity), eslint, lib (unit), types (type tests), build, and a build smoke. A contributor's PR runs the affected subset, so only packages touched by the change (and their dependents) are exercised.

Release model

Releases are changeset‑driven: each PR that changes published behavior adds a changeset describing the bump. changeset version rolls versions and changelogs; changeset publish pushes to npm. All @tanstack/* packages are versioned in lockstep (every package is 5.101.0 at this commit), so consumers can safely mix, e.g., react-query and react-query-devtools at the same version.


6. Security analysis

TanStack Query is a client‑side data‑management library; it does not open ports, read secrets, or execute remote code. Its security surface is therefore narrow and centers on a few areas worth reviewing in any deployment.

Threat surface

Area Consideration Mitigation in code
SSR error leakage Errors from pending queries are serialized into the HTML payload during dehydrate, potentially leaking server‑side error details to the client. dehydrate redacts errors by default: defaultShouldRedactErrors returns true, and pending‑query rejections are replaced with a generic Error('redacted') in production (the original is console.error‑logged only in non‑production). Override with shouldRedactErrors.
Cache persistence Persisters write cache data to localStorage/IndexedDB/AsyncStorage — readable by any script on the origin (XSS) and not encrypted. Treat persisted data as non‑sensitive. Do not persist auth tokens or PII; scope what is dehydrated with shouldDehydrateQuery. Persist‑client supports buster keys and maxAge to invalidate stale/poisoned caches.
Cross‑tab sync query-broadcast-client-experimental mirrors cache across tabs via BroadcastChannel, same‑origin only. Same‑origin scoping by the browser; still avoid broadcasting sensitive data.
queryFn is your code The library never constructs requests; it only runs the function you provide. Injection, auth, and URL‑building are the consumer's responsibility. N/A — by design. Forward signal to enable request cancellation and avoid resource leaks.
Supply chain The core has zero runtime dependencies, minimizing transitive‑dependency risk. Adapters depend only on query-core + their (peer) host framework. Lockstep versioning + publint/arethetypeswrong validation of published artifacts.

Negative knowledge (what the library does not do)

  • It does not read environment variables for configuration (only NODE_ENV for dev warnings).
  • It does not include an HTTP client, retry on its own network stack, or perform authentication.
  • It does not encrypt persisted caches.
  • It does not automatically retry mutations (default retry: 0), preventing accidental duplicate writes.

Important

The most common real‑world misconfiguration is persisting sensitive data. Because persisters serialize to plaintext browser storage, audit shouldDehydrateQuery and ensure auth/PII queries are excluded from any PersistQueryClientProvider.


7. Code quality & conventions

  • Encapsulation via native private fields. Internal state uses ECMAScript #private fields (#queryCache, #retryer, #observers), not TypeScript private, giving true runtime privacy.
  • State machines over ad‑hoc mutation. Query and Mutation mutate state only through reducers fed by typed action unions, making transitions auditable and testable.
  • Single base abstractions. Subscribable (observation) and Removable (GC) are reused across all stateful classes, keeping behavior consistent.
  • Framework‑agnostic core, thin adapters. Adapters contain almost no logic — React's useBaseQuery is ~150 lines that mostly wire an observer to useSyncExternalStore. New frameworks are cheap to add.
  • Render optimization by default. The trackResult Proxy + notifyOnChangeProps mean components re‑render only on fields they read — a correctness‑preserving performance feature baked into the core.
  • Pluggable seams. timeoutManager, notifyManager, focusManager, onlineManager, and environmentManager are all swappable, which is how the library adapts to React Native, tests (React.act integration), and high‑timer‑count environments without forking.
  • Strict typing. Heavy use of conditional/inferred generics (e.g. the useQueries tuple inference, DataTag typed keys) provides end‑to‑end type safety from queryFn return through select to the component.

8. Testing strategy

Test type Tooling What it covers
Unit/behavioral Vitest (jsdom) Cache logic, observers, retryer, hydration, each adapter's hooks (packages/*/src/__tests__).
Type‑level tsd‑style type tests via test:types The complex generic inference (queries tuples, typed keys) across the TS 5.4–6.0 matrix.
Bundle size size-limit Each package stays within its byte budget — a regression gate, since bundle size is a core selling point.
Lint/static ESLint + cspell + knip Code style, spelling, dead code.
Monorepo integrity sherif Dependency version consistency across all 27 packages.
Publish artifacts publint + arethetypeswrong Validates exports maps, dual ESM/CJS resolution, and .d.ts correctness.
Real‑toolchain smoke integrations/* The library installed into real Next.js/webpack/Vite/Angular‑CLI apps.

Note

A few internal tests around retry cooldowns rely on fake timers; the timeoutManager abstraction exists partly to make timer‑dependent behavior deterministically testable.


9. Operations & contributing

Adding a feature to the core

Because all behavior lives in query-core, most features are implemented there and surfaced through adapters only if they need framework‑specific glue (e.g. a new hook). The workflow:

  1. Implement in packages/query-core/src, with unit + type tests.
  2. Add a changeset (pnpm changeset).
  3. If user‑facing in a framework, thread it through the relevant adapter(s) and the docs/framework/* reference.
  4. pnpm test:ci must pass (the full eight‑target gate).

Adding a new framework adapter

Mirror an existing adapter's structure: re‑export @tanstack/query-core, create framework‑idiomatic wrappers around QueryObserver/MutationObserver/QueriesObserver using subscribe() + getCurrentResult(), and provide a client‑provider mechanism. Keep logic out of the adapter — push it into the core.

Versioning & migration

  • All packages release in lockstep; pin them to the same version.
  • Breaking changes ship as major versions with a codemod (@tanstack/query-codemods) and ESLint rules to ease migration (e.g. the v5 single‑object‑argument enforcement, surfaced at runtime as Bad argument type. Starting with v5, only the "Object" form is allowed…).

Operational tuning checklist

  • [ ] Set an app‑wide staleTime to control request volume.
  • [ ] Decide refetchOnWindowFocus/refetchOnReconnect policy per backend cost.
  • [ ] On SSR, set gcTime: Infinity server‑side and verify dehydrate error redaction.
  • [ ] If persisting, audit shouldDehydrateQuery to exclude sensitive data; set a buster key tied to your release.
  • [ ] Mount exactly one devtools instance and one QueryClient.
  • [ ] Enable @tanstack/eslint-plugin-query recommended rules in CI.

10. Quick reference

Public exports of @tanstack/query-core

QueryClient, QueryCache, MutationCache, Query, Mutation, QueryObserver, InfiniteQueryObserver, QueriesObserver, MutationObserver, focusManager, onlineManager, notifyManager, timeoutManager, environmentManager, dehydrate, hydrate, defaultShouldDehydrateQuery, defaultShouldDehydrateMutation, CancelledError, hashKey, matchQuery, matchMutation, partialMatchKey, replaceEqualDeep, keepPreviousData, skipToken, shouldThrowError, isServer, experimental_streamedQuery, plus all types.

Status reference

status fetchStatus Meaning
pending fetching First load in flight (isLoading).
pending paused Wants to load but offline.
success idle Has data, nothing running.
success fetching Has data, background refetch in flight (isRefetching).
error idle Fetch failed, holds the error.

Mutation status reference

idle → pending → success | error, surfaced as isIdle/isPending/isSuccess/isError, with reset() to return to idle.

Environment behavior matrix

Environment gcTime default retry default Focus/online listeners
Browser (client) 5 min 3 Active (via window)
Server (SSR) Infinity 0 No‑op (no window)
React Native 5 min 3 Inactive until wired via setEventListener