TanStack Query Server State
TL;DR: Server state is not client state — it goes stale on its own. TanStack Query v5 manages it with a stale-while-revalidate cache keyed by deterministic arrays. Build a typed query-key factory, set
staleTimedeliberately (not 0), invalidate by prefix, and on mutations always cancel-then-snapshot-then-write before optimistic updates.
Jump to your fire
| Symptom | Section |
|---|---|
| "Why does my query refetch on every focus / tab switch?" | Defaults & staleTime |
| "I changed the API but old data still shows" | Invalidation |
| "Optimistic update flickered back" | Optimistic updates |
| "Type-safe query keys exploding into typos" | Query key factory |
| "Page double-fetches on Next.js navigation" | Next.js hydration |
| "Mutation succeeded but UI didn't update" | onSuccess vs onSettled |
Decision diagram
flowchart TD
A[New data fetch needed] --> B{Is it server state<br/>read-only or req'd by URL?}
B -->|No, pure client state| C[Use useState / Zustand / Context]
B -->|Yes| D[Use TanStack Query]
D --> E{Mutates? POST/PUT/DELETE}
E -->|No, GET only| F[useQuery + queryKey from factory]
E -->|Yes| G[useMutation]
F --> H{Need to dedupe across components?<br/>Same key reused?}
H -->|Yes| I[Set sensible staleTime<br/>not 0 default]
G --> J{User-perceived latency matters?<br/>Will UI block on response?}
J -->|Yes| K[Add optimistic update<br/>cancel→snapshot→write→rollback→settle]
J -->|No| L[Just invalidate in onSuccess]
I --> M[On mutation, invalidate prefix not exact]
K --> M
L --> M
1. The defaults that bite
From TanStack v5 important defaults:
By default, queries consider cached data as stale. Inactive queries are garbage collected after 5 minutes.
That's the v5 ground truth. In practice:
| Option | Default | What it controls |
|---|---|---|
staleTime |
0 |
How long after fetch the data is considered fresh (no refetch on mount/focus) |
gcTime (was cacheTime in v4) |
5 * 60 * 1000 |
How long inactive (unobserved) cache entries survive before garbage collection |
refetchOnMount |
true |
Refetch when a component mounting subscribes to a stale query |
refetchOnWindowFocus |
true |
Refetch when the user returns to the tab |
refetchOnReconnect |
true |
Refetch when network comes back |
The default staleTime: 0 means every component mount re-hits your server for the same data, even if a sibling fetched it 50ms ago. The cache exists, but the query is "stale," so the network goes out anyway. This is the single most-cited "why is React Query making so many requests" complaint.
Set staleTime per query family deliberately:
// Reference data — barely changes
useQuery({ queryKey: keys.permissions(), staleTime: 'static' }) // never refetches automatically
// User profile — fine for a couple minutes
useQuery({ queryKey: keys.user.me(), staleTime: 2 * 60 * 1000 })
// Live feed — refetch aggressively
useQuery({ queryKey: keys.notifications(), staleTime: 10 * 1000 })
staleTime: 'static' blocks all background refetches; invalidateQueries still works. Use it for feature flags, permissions, and other immutable-ish reference data.
staleTime: Infinity is similar but 'static' is preferred in v5 when you really mean "this is reference data."
2. Query key factory pattern
From TanStack v5 Query Keys:
Query keys have to be an Array at the top level, and can be as simple as an Array with a single string, or as complex as an array of many strings and nested objects.
Deterministic hashing: object property order doesn't matter, but array element order does:
// Same query — objects compared by value:
['todos', { status: 'done', page: 1 }]
['todos', { page: 1, status: 'done' }] // ✅ matches
// Different queries — array order matters:
['todos', 'done', 1]
['todos', 1, 'done'] // ❌ different
Build a typed factory. Stop sprinkling ['todos', userId] strings around the codebase:
// keys.ts — single source of truth
export const todoKeys = {
all: ['todos'] as const,
lists: () => [...todoKeys.all, 'list'] as const,
list: (filters: TodoFilters) => [...todoKeys.lists(), filters] as const,
details: () => [...todoKeys.all, 'detail'] as const,
detail: (id: string) => [...todoKeys.details(), id] as const,
}
Now consumers get autocomplete and refactor safety:
useQuery({ queryKey: todoKeys.detail(id), queryFn: () => fetchTodo(id) })
useQuery({ queryKey: todoKeys.list({ status: 'open' }), queryFn: () => fetchTodos({ status: 'open' }) })
// And invalidation by prefix is trivial:
queryClient.invalidateQueries({ queryKey: todoKeys.lists() }) // all list variants
queryClient.invalidateQueries({ queryKey: todoKeys.all }) // every todos query
The hierarchy (all → lists → list(filters) → details → detail(id)) is what makes prefix invalidation in §3 work cleanly.
3. Invalidation: prefix matching
From TanStack v5 invalidation:
When a query is invalidated… it will be refetched if it's currently being rendered… it will be marked stale.
Default behavior is partial / prefix matching:
| Filter | Matches |
|---|---|
{ queryKey: ['todos'] } |
['todos'], ['todos', 1], ['todos', { status: 'open' }], ['todos', 'list', {...}] — everything that starts with 'todos' |
{ queryKey: ['todos'], exact: true } |
only ['todos'] |
{ queryKey: ['todos', { status: 'open' }] } |
only queries whose key includes that exact filter object (deep value compare) |
{ predicate: (q) => q.queryKey[0] === 'todos' && q.state.dataUpdatedAt < cutoff } |
full-control filtering by query state |
The single biggest invalidation mistake: invalidating only the exact key after a mutation that could affect list variants too.
// ❌ Updates the detail cache, but the listing page still shows stale row
queryClient.invalidateQueries({ queryKey: todoKeys.detail(id), exact: true })
// ✅ Invalidates the detail AND every list view at once
queryClient.invalidateQueries({ queryKey: todoKeys.all })
The factory makes this safe. Prefer broader invalidation; the cost is one refetch per active observer, not one per cached entry.
4. Optimistic updates: the cancel-snapshot-rollback dance
From TanStack v5 optimistic updates, the canonical four-step pattern:
useMutation({
mutationFn: addTodo,
// 1. CANCEL in-flight refetches that would overwrite our optimistic write
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: todoKeys.lists() })
// 2. SNAPSHOT current cache (used for rollback)
const previousTodos = queryClient.getQueryData(todoKeys.lists())
// 3. WRITE the optimistic value
queryClient.setQueryData(todoKeys.lists(), (old: Todo[] = []) => [...old, newTodo])
// Return context for onError
return { previousTodos }
},
// 4a. ROLLBACK on failure using the snapshot
onError: (err, newTodo, context) => {
if (context?.previousTodos) {
queryClient.setQueryData(todoKeys.lists(), context.previousTodos)
}
},
// 4b. SETTLE — refetch from server regardless of success/failure
onSettled: () => {
queryClient.invalidateQueries({ queryKey: todoKeys.lists() })
},
})
Why each step matters:
- Cancel first — without it, an in-flight
GET /todosstarted 200ms ago lands after yoursetQueryDataand silently overwrites the optimistic value with stale server data. The user sees their addition appear, then disappear, then reappear after refetch. Worst-of-both-worlds flicker. - Snapshot before write — you cannot reconstruct cache state from variables alone (server-assigned IDs, timestamps, computed fields). Snapshot is the only safe source of rollback truth.
- Settle with invalidate — even on success, the server may have computed something different (auto-tags, normalized whitespace, conflict resolution). Invalidating after settled keeps client and server consistent.
onSuccess vs onSettled: onSuccess only runs if the mutation didn't throw. onSettled runs in both cases. For invalidation after a mutation, prefer onSettled — you want the server's truth even after a 500.
5. Next.js App Router hydration
The Server-Component-friendly pattern: prefetch on the server, hydrate the cache on the client.
// app/todos/page.tsx — Server Component
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'
import TodoList from './todo-list' // Client Component
export default async function Page() {
const queryClient = new QueryClient()
await queryClient.prefetchQuery({
queryKey: todoKeys.lists(),
queryFn: fetchTodos,
})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<TodoList />
</HydrationBoundary>
)
}
// app/todos/todo-list.tsx — Client Component
'use client'
import { useQuery } from '@tanstack/react-query'
export default function TodoList() {
const { data } = useQuery({ queryKey: todoKeys.lists(), queryFn: fetchTodos })
// First render reads from hydrated cache — zero waterfall, zero loading state
return <ul>{data?.map(t => <li key={t.id}>{t.title}</li>)}</ul>
}
Required: a fresh QueryClient per request on the server. Sharing a client across requests leaks one user's data into another's response.
For the app-wide QueryClientProvider, use the singleton pattern that creates the client once on the client and once-per-request on the server (see TanStack's Next.js App Router guide).
6. useSuspenseQuery (when to use)
useSuspenseQuery throws a Promise during loading instead of returning { isLoading: true }. Use it when:
- A
<Suspense>boundary above the component handles the loading state cleanly. - You want
datato be non-null in the component body (eliminatesif (!data) return nullboilerplate). - The route already has Server-Component-prefetched data, so suspense resolves instantly on first render.
Don't use it when:
- You need conditional fetching (
enabled: false) — Suspense queries throw if disabled. - Multiple suspense queries in the same component cause sequential waterfalls — combine with
useSuspenseQueriesfor parallel fetching, or prefetch all on the server.
Anti-patterns
| Anti-pattern | Why it bites | Fix |
|---|---|---|
staleTime: 0 everywhere (the default) |
Every mount re-fetches; siblings duplicate requests | Per-query-family staleTime based on actual change frequency |
| Inline string query keys | Typos invalidate nothing; refactors break silently | Factory in queries/keys.ts, typed |
invalidateQueries({ queryKey, exact: true }) after mutation |
List/detail views diverge | Invalidate by prefix (the factory's parent level) |
Optimistic update without cancelQueries first |
Flicker as in-flight GET overwrites your write | Always await cancelQueries first |
Sharing one QueryClient across SSR requests |
Cross-user data leak | Create per-request client on server |
refetchInterval: 1000 for "real-time" data |
Burns server budget; misses events | Use SSE/WebSocket for actual real-time, polling only as fallback |
| Using TanStack Query for pure client state (form drafts, modal open/closed) | Cache layer adds nothing; weird invalidation semantics | useState / Zustand / useReducer |
Global default gcTime: Infinity |
Cache balloons; rarely-used pages keep memory forever | Leave default 5 min unless a query is small + accessed often |
Novice / Expert / Timeline
| Novice | Expert | |
|---|---|---|
| First useQuery | Inline string key, default options | Factory key, deliberate staleTime, considered gcTime |
| After a mutation | setQueryData to specific cache and prays |
onMutate snapshot + onSettled invalidate prefix |
| Sees too many requests | Adds refetchOnWindowFocus: false globally |
Diagnoses with React Query Devtools, sets per-query staleTime |
| Adds optimistic updates | Forgets cancelQueries, gets flicker |
Cancel → snapshot → write → rollback → settle |
| Multi-tenant SSR | Reuses singleton QueryClient |
Per-request client; Server Component prefetch + HydrationBoundary |
Timeline test: open React Query Devtools on the home page, navigate to a detail page and back. An expert codebase shows ≤1 fetch per query family per minute on this round-trip. A novice codebase shows the same query firing 4-8 times.
Quality gates
Server-state changes ship when:
- Test: A query key factory exists in a single file (
queries/keys.tsor similar); grep finds zero inline['todos', ...]literals outside it. - Test: Each query family has an explicit
staleTime(not relying on the0default) — verified by lint rule or PR-time grep foruseQuerywithoutstaleTime. - Test: Mutation handlers either invalidate via factory prefix OR do a full optimistic-update flow (onMutate cancel+snapshot+write, onError rollback, onSettled invalidate).
- Test: Devtools open during a typical navigation flow shows no repeated fetches of the same query within the configured
staleTimewindow. - Test (SSR):
QueryClientis created per request on the server (no module-level singleton); a multi-user load test shows zero cross-user data appearing. - Manual: Optimistic update flicker test — disable network, perform a mutation, watch for the value bouncing. It should not.
NOT for this skill
- Generic React state management (use
react-state-management) - Form state — use
react-hook-formor similar; TanStack Query is for server state - GraphQL-specific caching (use
urql-or-apollo-cache-design) - Real-time / WebSocket subscriptions — TanStack Query polls; use a WebSocket library for true push
- TanStack Router — different package, different scope (use
tanstack-router-design)
Sources
- TanStack Query v5: Important Defaults — staleTime, gcTime, refetch triggers
- TanStack Query v5: Query Keys — array structure, deterministic hashing
- TanStack Query v5: Query Invalidation — prefix vs exact match
- TanStack Query v5: Optimistic Updates — cancelQueries → snapshot → setQueryData → rollback → settle
- TanStack Query v5: Advanced SSR / Next.js App Router — HydrationBoundary, per-request QueryClient
- Lukemorales: Query Key Factory — community pattern that inspired the typed-factory style