TanStack Query skill for Claude Code
Server state with keys, staleTime and rollback. Claude treats server data as a cache with rules. It builds one query key factory with queryOptions, picks staleTime by how fast each kind of data changes, makes mutations invalidate exactly the keys they touch, rolls back optimistic updates on error, and hydrates SSR caches so pages do not refetch on load. Verification uses Devtools and the network tab, not hope.
- name
tanstack-query - size228 lines · ≈2,500 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Implements server-state fetching with TanStack Query v5 — query key factories and queryOptions, staleTime and gcTime chosen per data type, dependent and parallel queries, pagination and infinite queries, mutations with targeted invalidation or optimistic updates and rollback, prefetching, Suspense, and SSR hydration for Next.js, React Router, and other frameworks. Use when adding useQuery or useMutation, data refetches too often or goes stale, a list does not update after a mutation, optimistic UI flickers, or when replacing useEffect fetching or Redux server caches. Try asking
- Replace the useEffect fetching in this dashboard with TanStack Query
- The todo list does not update after I add an item — fix the invalidation
- Make the like button optimistic and roll back if the request fails
Use it when
- Adding useQuery, useMutation or useInfiniteQuery to a React app
- Data refetches constantly or shows stale values after a mutation
- Prefetching and hydrating query data from Next.js or React Router
- Migrating from v4 or from Redux-based server caches
Skip it when
- Deciding whether state belongs in URL, client store or server cache — use state-management first
- The app is React Router framework mode and loaders already cover the data — use react-router-framework
How to check the result
Devtools shows one key per resource with fresh or stale status matching the chosen staleTime, back navigation makes no request, and a forced 500 rolls optimistic changes back.
The common mistake it prevents
Leaving a variable the queryFn uses out of the query key, so different filters or users share one cache entry and show the wrong data.
Server state is a cache of someone else’s data. Do it well and every query has a
structured key from one factory, a staleTime chosen on purpose, mutations invalidate
exactly the keys they affect, optimistic updates roll back on error, and SSR hands the
cache to the client so nothing refetches on hydration.
Step 0 — inspect before changing
npm ls @tanstack/react-query @tanstack/react-query-devtools # v5 expected (5.x)
grep -rn "new QueryClient" src app | head
grep -rn "queryKey:" src app | head -30 # how keys are built today
grep -rn "useEffect" src app | grep -i fetch | head # hand-rolled fetching to replace
v4 → v5 renames to watch for in existing code: cacheTime → gcTime, isLoading (for
first load) → isPending, keepPreviousData: true → placeholderData: keepPreviousData,
single-object signature only (useQuery({ queryKey, queryFn })), onSuccess/onError removed
from useQuery. TanStack ships a v5 codemod (remove-overloads); take the exact
jscodeshift command from the official v5 migration guide and review its diff by hand.
QueryClient defaults
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000, // default 0 = refetch on every mount/focus; rarely what you want
retry: (count, err) => !(err instanceof HttpError && err.status < 500) && count < 2,
refetchOnWindowFocus: true, // keep; it is cheap with a sane staleTime
},
},
});
Create the client once per app in the browser (useState(() => new QueryClient()) or
module scope in an SPA) and once per request on the server. A module-level client on a
server shares one user’s data with the next request.
staleTime by data type
| Data | staleTime | Notes |
|---|---|---|
| Reference data (countries, plans, feature config) | Infinity or hours | Invalidate on deploy or explicit change |
| User profile, settings | 5 min | Invalidate on the user’s own edits |
| Lists users edit collaboratively | 10–30 s | Plus invalidation after mutations |
| Dashboards, prices, inventory | 0–10 s, maybe refetchInterval | Pause polling when tab hidden (default) |
| Search-as-you-type results | 60 s | Keyed by query term so back/forward is instant |
gcTime (default 5 min) is how long an unused cache entry survives. Raise it only for
data users navigate back to often.
Query key factory
// src/features/projects/queries.ts
import { queryOptions, infiniteQueryOptions } from "@tanstack/react-query";
export const projectKeys = {
all: ["projects"] as const,
lists: () => [...projectKeys.all, "list"] as const,
list: (filters: ProjectFilters) => [...projectKeys.lists(), filters] as const,
details: () => [...projectKeys.all, "detail"] as const,
detail: (id: string) => [...projectKeys.details(), id] as const,
};
export const projectQuery = (id: string) =>
queryOptions({
queryKey: projectKeys.detail(id),
queryFn: ({ signal }) => api.get<Project>(`/projects/${id}`, { signal }),
staleTime: 60_000,
});
export const projectsQuery = (filters: ProjectFilters) =>
queryOptions({
queryKey: projectKeys.list(filters),
queryFn: ({ signal }) => api.get<Project[]>("/projects", { params: filters, signal }),
});
- Every variable the
queryFnuses must be in the key. Missing one is the top cause of “shows the wrong user’s/filter’s data”. - Keys go general → specific so
invalidateQueries({ queryKey: projectKeys.lists() })hits every filter combination at once. queryOptionsgives type inference foruseQuery,prefetchQuery,getQueryData, andsetQueryDatafrom one definition. Reuse it everywhere; never retype keys inline.- Pass
signalto fetch so superseded requests are aborted.
Reading data
const { data, isPending, isError, error, isFetching } = useQuery(projectQuery(id));
if (isPending) return <Skeleton />;
if (isError) return <ErrorState error={error} />;
return <ProjectView project={data} refreshing={isFetching} />;
| Pattern | How |
|---|---|
| Dependent query | enabled: !!userId (or skipToken as queryFn for type-safe disabling) |
| Parallel, dynamic count | useQueries({ queries: ids.map(projectQuery) }) |
| Paginated table without flashing empty | placeholderData: keepPreviousData |
| Infinite scroll | useInfiniteQuery with initialPageParam and getNextPageParam |
| Derived/selected slice | select: (d) => d.items.filter(...) (memoize the selector if expensive) |
| Suspense | useSuspenseQuery(projectQuery(id)) inside <Suspense> + error boundary |
| Prefetch on hover/intent | queryClient.prefetchQuery(projectQuery(id)) |
| Seed detail from list | initialData: () => getQueryData(lists)?.find(...), with initialDataUpdatedAt |
Never copy query data into useState to edit it; keep the form state separate and
initialize it from data once (key the form component by id).
Mutations
Default: invalidate, which is simple and correct.
const qc = useQueryClient();
const rename = useMutation({
mutationFn: (v: { id: string; name: string }) => api.patch(`/projects/${v.id}`, { name: v.name }),
onSuccess: (updated, v) => {
qc.setQueryData(projectQuery(v.id).queryKey, updated); // we have the fresh entity
return qc.invalidateQueries({ queryKey: projectKeys.lists() }); // returned promise keeps isPending until refetch done
},
});
Optimistic update when latency is visible and the outcome is near-certain (toggles, reorder, rename):
const toggle = useMutation({
mutationFn: (v: { id: string; done: boolean }) => api.patch(`/todos/${v.id}`, v),
onMutate: async (v) => {
const key = todoKeys.list(filters);
await qc.cancelQueries({ queryKey: key }); // stop in-flight refetch overwriting us
const previous = qc.getQueryData<Todo[]>(key);
qc.setQueryData<Todo[]>(key, (old) => old?.map((t) => (t.id === v.id ? { ...t, done: v.done } : t)));
return { previous, key };
},
onError: (_err, _v, ctx) => ctx && qc.setQueryData(ctx.key, ctx.previous), // roll back
onSettled: (_d, _e, _v, ctx) => qc.invalidateQueries({ queryKey: ctx?.key ?? todoKeys.lists() }),
});
For a single component that only needs to show the pending item, skip cache surgery:
render toggle.variables while toggle.isPending (or read across components with
useMutationState).
Decision table:
| Situation | Approach |
|---|---|
| Server response returns the full updated entity | setQueryData for detail + invalidate lists |
| Mutation affects many queries in unknown ways | invalidateQueries({ queryKey: ["projects"] }) |
| Create/delete in a visible list, fast feedback needed | Optimistic insert/remove with rollback |
| Rapid repeated mutations (typing, sliders) | Debounce the mutate call; set mutationKey and scope to serialize |
| Mutation result must be confirmed before UI changes (payments) | No optimism; disable button with isPending |
SSR hydration
Pattern for every framework: create a request-scoped client, prefetchQuery with the
same queryOptions, dehydrate, wrap the client tree in HydrationBoundary, and set
staleTime > 0 so the client does not refetch immediately.
// Next.js App Router server component
export default async function Page({ params }: PageProps<"/projects/[id]">) {
const { id } = await params;
const qc = getQueryClient(); // new client per request on the server
await qc.prefetchQuery(projectQuery(id));
return (
<HydrationBoundary state={dehydrate(qc)}>
<ProjectClient id={id} />
</HydrationBoundary>
);
}
Framework specifics (Next.js streaming with pending queries, React Router loaders,
Nuxt/Vue, getQueryClient with isServer) are in
references/ssr-hydration.md — read it whenever the task
involves server rendering.
Gotchas
isPendingvsisFetching: pending means no data yet; fetching means any request in flight including background refetch. Show skeletons on pending, a subtle indicator on fetching.- Keys are hashed deterministically (object property order does not matter), but they must be JSON-serializable: convert Dates, Maps and class instances to strings first.
invalidateQueriesrefetches only active queries by default; inactive ones are marked stale and refetch on next mount. That is usually right.- Throwing in
queryFnis required for errors;fetchdoes not reject on 4xx/5xx — checkres.okand throw. enabled: falsequeries ignoreinvalidateQueries; callrefetch()explicitly.- Devtools:
<ReactQueryDevtools initialIsOpen={false} />from@tanstack/react-query-devtools— tree-shaken out of production builds. - Do not mirror query data into Redux/Zustand. If a client store needs it, read it from
the query cache with
useQueryandselect.
Verification
- Open React Query Devtools: each screen shows the expected keys, fresh/stale status
matches the chosen
staleTime, and no duplicate keys for the same resource. - Network tab: navigating back to a screen within
staleTimemakes no request; focus refetch happens only for stale queries. - Force a mutation error (devtools offline or a 500 mock) and confirm optimistic changes roll back and an error is shown.
- SSR: view source shows data in HTML, and the first client render makes no request for prefetched keys.
- Tests: wrap components in a fresh
QueryClientper test withretry: false.
Report format
**Keys:** projectKeys factory (all → lists → list(filters) → detail(id))
**staleTime:** projects 60 s, plans Infinity, dashboard 5 s + refetchInterval 30 s
**Mutations:** rename → setQueryData detail + invalidate lists; toggleTodo optimistic w/ rollback
**SSR:** /projects/[id] prefetched + HydrationBoundary; no client refetch on load
**Verified:** devtools keys, offline rollback test, network tab back-navigation Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
references/ssr-hydration.md
# TanStack Query SSR hydration by framework
Read this when a task renders queries on the server. The invariant in every framework:
1. One `QueryClient` per server request (never a module singleton on the server).
2. Prefetch with the same `queryOptions` the client component uses, so keys match exactly.
3. Serialize with `dehydrate(client)` and restore with `HydrationBoundary` (React) or
`hydrate(client, state)` (Vue/Solid/Svelte).
4. `staleTime > 0` on the client, otherwise hydrated data is immediately stale and
refetched on mount, doubling every request.
5. Do not dehydrate secrets: everything in the cache ends up in the HTML.
## Next.js App Router
```ts
// app/get-query-client.ts
import { QueryClient, defaultShouldDehydrateQuery, isServer } from "@tanstack/react-query";
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { staleTime: 60_000 },
dehydrate: {
// include pending queries so prefetches started without await can stream
shouldDehydrateQuery: (q) => defaultShouldDehydrateQuery(q) || q.state.status === "pending",
},
},
});
}
let browserClient: QueryClient | undefined;
export function getQueryClient() {
if (isServer) return makeQueryClient(); // fresh per request
return (browserClient ??= makeQueryClient()); // stable in the browser, survives Suspense
}
```
```tsx
// app/providers.tsx
"use client";
export function Providers({ children }: { children: React.ReactNode }) {
const qc = getQueryClient(); // do not useState here: Suspense can discard it
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
}
```
Choosing await vs stream in a Server Component:
| Goal | Server Component | Client Component |
|---|---|---|
| Data in initial HTML, simplest | `await qc.prefetchQuery(opts)` | `useQuery(opts)` |
| Stream: shell first, data when ready | `void qc.prefetchQuery(opts)` (no await) + pending dehydration above | `useSuspenseQuery(opts)` under `<Suspense>` |
| Data only needed in the server tree | Fetch directly; do not involve Query | — |
Notes:
- Each `HydrationBoundary` can be placed per page or per section; nested ones merge.
- Server Components do not re-run on client cache invalidation. If the server tree
renders query data too, it goes stale while the client copy refreshes. Render the
data in client components only, or accept that the server copy is a first paint.
- With Next caching (`use cache`, fetch revalidate) the server fetch may be cached; the
Query cache does not know about that layer. Keep invalidation consistent on both sides.
## React Router framework mode
Two workable patterns:
1. Loader-driven (simplest): use loaders as the source of truth and skip Query for
route data; use Query only for client-side widgets.
2. Query as cache over loaders:
```ts
// root.tsx loader or per route
export async function loader({ params }: Route.LoaderArgs) {
const qc = new QueryClient({ defaultOptions: { queries: { staleTime: 60_000 } } });
await qc.prefetchQuery(projectQuery(params.projectId));
return { dehydratedState: dehydrate(qc) };
}
export default function Project({ loaderData, params }: Route.ComponentProps) {
return (
<HydrationBoundary state={loaderData.dehydratedState}>
<ProjectView id={params.projectId} /> {/* uses useQuery(projectQuery(id)) */}
</HydrationBoundary>
);
}
```
For SPA mode use `clientLoader` with `queryClient.ensureQueryData(projectQuery(id))`
against the browser client; navigation then reuses cached data within `staleTime`.
After an `action`, React Router revalidates loaders but the Query cache is separate:
invalidate the matching keys in the mutation or in a `clientAction`.
## Vue / Nuxt
- Install `VueQueryPlugin` with a client created in a Nuxt plugin.
- Server: after render, put `dehydrate(queryClient)` into `nuxtApp.payload` (hook
`app:rendered`). Client: `hydrate(queryClient, nuxtApp.payload.vueQueryState)` on
creation.
- In components, await `suspense()` from `useQuery` inside `onServerPrefetch` so the
server waits for data.
- Many Nuxt apps are better served by `useFetch`/`useAsyncData` alone; add Vue Query only
for caching needs those do not meet (infinite queries, optimistic cache updates).
## Debugging hydration
| Symptom | Likely cause |
|---|---|
| Client refetches immediately after load | `staleTime` 0 on the browser client, or keys differ (e.g. number vs string id) |
| Data from another user appears | Server QueryClient created at module scope |
| "Hydration mismatch" text differences | Client rendered before hydrate, or `select`/formatting depends on locale/time |
| Prefetched query missing on client | Dehydrated from a different client than the one prefetched, or `shouldDehydrateQuery` excluded it (errors excluded by default) |
| Infinite query hydrated with one page only | Expected; `getNextPageParam` continues from hydrated pages |
Check: view the page source (or the RSC payload in the network tab) and search for a
value from the prefetched data, then confirm the Devtools panel shows the query as
fresh on first render and the network tab shows no request for that key.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.