Designing TanStack Query (React Query) v5 for server-state management — query key factories, staleTime vs gcTime, partial-match invalidation, optimistic updates with rollback, prefetching, and Next.js App Router hydration. Grounded in tanstack.com v5 docs. NOT for pure client state (useState/Zustand), form state, GraphQL cache design, or true real-time push (WebSockets/SSE).
Installs into .claude/skills of the current project.
Are you the author of Tanstack Query Server State?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/curiositech-tanstack-query-server-state-port-daddy)
---
license: Apache-2.0
name: tanstack-query-server-state
description: Designing TanStack Query (React Query) v5 for server-state management — query key factories, staleTime vs gcTime, partial-match invalidation, optimistic updates with rollback, prefetching, and Next.js App Router hydration. Grounded in tanstack.com v5 docs. NOT for pure client state (useState/Zustand), form state, GraphQL cache design, or true real-time push (WebSockets/SSE).
allowed-tools: Read, Grep, Glob, Edit, Write, Bash(grep:*, rg:*, find:*)
metadata:
category: Frontend Frameworks
tags: [react, tanstack-query, react-query, server-state, caching, optimistic-updates, nextjs]
provenance:
kind: first-party
owners: [port-daddy]
pairs-with:
- skill: ideal-web-app-builder
reason: The app-scaffolding skill whose Next.js App Router data layer this skill's HydrationBoundary/per-request-QueryClient pattern slots into.
- skill: websocket-realtime-expert
reason: When the anti-pattern table says "stop polling with refetchInterval," true real-time push moves to that skill's territory.
io-contract:
kind: deliverable
consumes:
- kind: data-fetching-requirement
format: markdown
description: A description of the app's server-state needs -- query families, change frequency, mutation flows, SSR/Next.js posture.
- kind: query-layer-plan
format: json
description: A structured plan naming key source, staleTime policy, invalidation scope, and the optimistic-update/SSR posture, matching schemas/query-layer-plan.schema.json.
produces:
- kind: server-state-design
format: markdown
description: The query-layer design -- typed key factory, per-family staleTime, invalidation strategy, mutation flows, hydration setup.
- kind: query-layer-audit
format: json
description: A deterministic pass/fail audit of the query-layer-plan against this skill's Quality Gates, as produced by scripts/tanstack_query_audit.mjs.
---
# 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 `staleTime` deliberately (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](#1-the-defaults-that-bite) |
| "I changed the API but old data still shows" | [Invalidation](#3-invalidation-prefix-matching) |
| "Optimistic update flickered back" | [Optimistic updates](#4-optimistic-updates-the-cancel-snapshot-rollback-dance) |
| "Type-safe query keys exploding into typos" | [Query key factory](#2-query-key-factory-pattern) |
| "Page double-fetches on Next.js navigation" | [Next.js hydration](#5-nextjs-app-router-hydration) |
| "Mutation succeeded but UI didn't update" | [onSuccess vs onSettled](#4-optimistic-updates-the-cancel-snapshot-rollback-dance) |
---
## Decision diagram
```mermaid
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](https://tanstack.com/query/latest/docs/framework/react/guides/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:**
```ts
// 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](https://tanstack.com/query/latest/docs/framework/react/guides/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:
```ts
// 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:
```ts
// 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:
```ts
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](https://tanstack.com/query/v5/docs/react/guides/query-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.
```ts
// ❌ 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](https://tanstack.com/query/v5/docs/react/guides/optimistic-updates), the canonical four-step pattern:
```ts
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 /todos` started 200ms ago lands *after* your `setQueryData` and 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**.
```tsx
// 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>
)
}
```
```tsx
// 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](https://tanstack.com/query/latest/docs/framework/react/guides/advanced-ssr)).
---
## 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 `data` to be non-null in the component body (eliminates `if (!data) return null` boilerplate).
- 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 `useSuspenseQueries` for 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.ts` or similar); grep finds zero inline `['todos', ...]` literals outside it.
- [ ] **Test:** Each query family has an explicit `staleTime` (not relying on the `0` default) — verified by lint rule or PR-time grep for `useQuery` without `staleTime`.
- [ ] **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 `staleTime` window.
- [ ] **Test (SSR):** `QueryClient` is 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.
---
## Deterministic Audit
Before committing to a query-layer design (or reviewing another agent's), write it as a
JSON plan matching `schemas/query-layer-plan.schema.json` and run the deterministic
auditor:
```bash
node scripts/tanstack_query_audit.mjs --input examples/sample-input.json
```
`auditTanstackQuery(plan)` (in `scripts/tanstack_query_audit.mjs`) turns this skill's
anti-patterns and Quality Gates into machine-checkable rules over structured fields —
no keyword matching: TanStack Query wrapped around pure client state, inline string
keys instead of a typed factory, the `staleTime: 0` default left in place, exact-match
invalidation after mutations, an optimistic update that skips cancel or snapshot, a
shared `QueryClient` across SSR requests, and sub-5s polling standing in for real-time
push. It returns `{ pass, score, findings, recommendations }`.
`examples/sample-input.json` is a factory-keyed, per-request-client SSR plan
(`pass: true`). Version history lives in `CHANGELOG.md`.
---
## NOT for this skill
- Generic React state management (use `react-state-management`)
- Form state — use `react-hook-form` or 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](https://tanstack.com/query/latest/docs/framework/react/guides/important-defaults) — staleTime, gcTime, refetch triggers
- TanStack Query v5: [Query Keys](https://tanstack.com/query/latest/docs/framework/react/guides/query-keys) — array structure, deterministic hashing
- TanStack Query v5: [Query Invalidation](https://tanstack.com/query/v5/docs/react/guides/query-invalidation) — prefix vs exact match
- TanStack Query v5: [Optimistic Updates](https://tanstack.com/query/latest/docs/framework/react/guides/optimistic-updates) — cancelQueries → snapshot → setQueryData → rollback → settle
- TanStack Query v5: [Advanced SSR / Next.js App Router](https://tanstack.com/query/latest/docs/framework/react/guides/advanced-ssr) — HydrationBoundary, per-request QueryClient
- Lukemorales: [Query Key Factory](https://github.com/lukemorales/query-key-factory) — community pattern that inspired the typed-factory style