Frontend Editor's pick

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.

  • nametanstack-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.

~/.claude/skills/tanstack-query/SKILL.md

--- name: tanstack-query description: Implements server-state fetching with TanStack Query v5 — query key factories and queryOptions, staleTime and gcTime chosen per data type,… license: MIT ---

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

DatastaleTimeNotes
Reference data (countries, plans, feature config)Infinity or hoursInvalidate on deploy or explicit change
User profile, settings5 minInvalidate on the user’s own edits
Lists users edit collaboratively10–30 sPlus invalidation after mutations
Dashboards, prices, inventory0–10 s, maybe refetchIntervalPause polling when tab hidden (default)
Search-as-you-type results60 sKeyed 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 queryFn uses 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.
  • queryOptions gives type inference for useQuery, prefetchQuery, getQueryData, and setQueryData from one definition. Reuse it everywhere; never retype keys inline.
  • Pass signal to 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} />;
PatternHow
Dependent queryenabled: !!userId (or skipToken as queryFn for type-safe disabling)
Parallel, dynamic countuseQueries({ queries: ids.map(projectQuery) })
Paginated table without flashing emptyplaceholderData: keepPreviousData
Infinite scrolluseInfiniteQuery with initialPageParam and getNextPageParam
Derived/selected sliceselect: (d) => d.items.filter(...) (memoize the selector if expensive)
SuspenseuseSuspenseQuery(projectQuery(id)) inside <Suspense> + error boundary
Prefetch on hover/intentqueryClient.prefetchQuery(projectQuery(id))
Seed detail from listinitialData: () => 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:

SituationApproach
Server response returns the full updated entitysetQueryData for detail + invalidate lists
Mutation affects many queries in unknown waysinvalidateQueries({ queryKey: ["projects"] })
Create/delete in a visible list, fast feedback neededOptimistic 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

  • isPending vs isFetching: 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.
  • invalidateQueries refetches only active queries by default; inactive ones are marked stale and refetch on next mount. That is usually right.
  • Throwing in queryFn is required for errors; fetch does not reject on 4xx/5xx — check res.ok and throw.
  • enabled: false queries ignore invalidateQueries; call refetch() 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 useQuery and select.

Verification

  1. 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.
  2. Network tab: navigating back to a screen within staleTime makes no request; focus refetch happens only for stale queries.
  3. Force a mutation error (devtools offline or a 500 mock) and confirm optimistic changes roll back and an error is shown.
  4. SSR: view source shows data in HTML, and the first client render makes no request for prefetched keys.
  5. Tests: wrap components in a fresh QueryClient per test with retry: 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 117 lines · 5.1 KB
# 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.

esc