TanStack Query skill for Codex
Exact keys, deliberate staleTime, honest mutations. Codex builds one query key factory per domain with queryOptions, sets staleTime from how the data actually changes, and makes every mutation name the keys it invalidates or patches. Optimistic updates cancel in-flight queries and roll back on error, SSR uses a per-request QueryClient with HydrationBoundary, and v4 code is upgraded with the renamed options in mind.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- tanstack-query Installs to ~/.agents/skills/tanstack-query/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install tanstack-query The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project tanstack-query Installs to .agents/skills/tanstack-query/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/tanstack-query.zipunzip -o tanstack-query.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) tanstack-query PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $tanstack-query, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Implements TanStack Query v5 data fetching in React — query key factories with queryOptions, staleTime and gcTime choices, dependent and paginated queries, infinite queries, mutations with targeted invalidation or optimistic updates and rollback, Suspense queries, and SSR prefetch with HydrationBoundary. Use when adding server-state fetching, when data is refetched too often or shows stale after a mutation, when writing optimistic updates, when upgrading from React Query v4, or when hydrating queries from Next.js or another SSR framework.
Use it when
- Adding server-state fetching to a React app
- Data refetches too often or stays stale after a mutation
- Writing optimistic updates or infinite scrolling lists
- Upgrading React Query v4 code to v5 or hydrating from SSR
Use something else when
- The app already uses RTK Query for server state: use redux-toolkit
- The state is purely client-side UI state: use zustand-state
How to check the result
Devtools show keys in the factory shape, navigating within staleTime triggers no request, and a forced API failure rolls back the optimistic change with an error shown.
The mistake it prevents
Leaving staleTime at the default 0 while hydrating from the server, so every hydrated query immediately refetches on mount.
Done well means: server state lives only in the query cache (never copied into
useState or a global store), keys are produced by one factory so invalidation is
exact, staleTime is a deliberate number per data type, and every mutation states which
queries it invalidates or how it updates the cache and rolls back.
Before writing code
- Read
AGENTS.md, then check@tanstack/react-queryinpackage.json. This skill targets v5 (object-only signatures,isPending,gcTime). For v4 code, plan the upgrade first (see below). - Find the
QueryClientsetup and itsdefaultOptions. Changes to defaults affect the whole app; prefer per-query options. - Find existing key conventions (
grep -rn "queryKey" src | head -30) and follow them. - Check for
@tanstack/react-query-devtools; it is the fastest way to see cache state.
Keys and options: one factory per domain
// src/features/projects/queries.ts
import { queryOptions, infiniteQueryOptions, keepPreviousData } 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 projectQueries = {
list: (filters: ProjectFilters) =>
queryOptions({
queryKey: projectKeys.list(filters),
queryFn: ({ signal }) => api.listProjects(filters, { signal }),
staleTime: 30_000,
placeholderData: keepPreviousData, // no flash to loading between pages
}),
detail: (id: string) =>
queryOptions({
queryKey: projectKeys.detail(id),
queryFn: ({ signal }) => api.getProject(id, { signal }),
staleTime: 60_000,
}),
};
- Every variable the
queryFnuses must be in the key. A missing variable means two different requests share one cache entry. queryOptionsgives type inference foruseQuery,useSuspenseQuery,prefetchQuery,getQueryDataandsetQueryDatafrom a single definition.- Pass
signalto fetch so React Query can cancel outdated requests. - Keys are hashed deterministically; object key order does not matter, array order does.
Choosing staleTime and gcTime
| Data | staleTime | Notes |
|---|---|---|
| Reference data (countries, plans, feature flags) | Infinity or hours | invalidate explicitly when it changes |
| Lists and detail views edited by the user | 30 s to 5 min | rely on invalidation after mutations |
| Collaborative or live data | 0 to 10 s, plus refetchInterval or websockets | |
| Data fetched on the server and hydrated | above 0 | otherwise the client refetches immediately on mount |
Default staleTime is 0, so every mount and window focus refetches. That is the most
common “why does it fetch so much” answer. gcTime (default 5 min) controls how long
unused entries stay in memory; it is not a freshness setting.
Reading data
const { data, isPending, isError, error, isFetching } = useQuery(projectQueries.detail(id));
isPending: no data yet (first load).isFetching: any request in flight, including background refetches. Show a skeleton for the first and a subtle indicator for the second.- Dependent query:
enabled: !!userId. Or useskipTokenas thequeryFnfor type-safe disabling. - Derive with
selectinstead of transforming in the component; components re-render only when the selected value changes. - With
useSuspenseQuery, data is always defined; loading goes to<Suspense>and errors to an error boundary. Do not mix it withenabled.
Infinite lists:
infiniteQueryOptions({
queryKey: ["feed", filters],
queryFn: ({ pageParam, signal }) => api.feed({ cursor: pageParam, ...filters }, { signal }),
initialPageParam: null as string | null,
getNextPageParam: (last) => last.nextCursor ?? undefined, // undefined = no more pages
maxPages: 10, // bound memory for long sessions
});
Mutations
Default: invalidate what changed.
const queryClient = useQueryClient();
const rename = useMutation({
mutationFn: (input: { id: string; name: string }) => api.renameProject(input),
onSuccess: (updated) => {
queryClient.setQueryData(projectKeys.detail(updated.id), updated); // server truth
return queryClient.invalidateQueries({ queryKey: projectKeys.lists() }); // returning waits
},
});
Optimistic update with rollback, only when latency is visible and failure is rare:
useMutation({
mutationFn: api.toggleFavorite,
onMutate: async ({ id, favorite }) => {
await queryClient.cancelQueries({ queryKey: projectKeys.detail(id) });
const previous = queryClient.getQueryData(projectKeys.detail(id));
queryClient.setQueryData(projectKeys.detail(id), (old) => (old ? { ...old, favorite } : old));
return { previous };
},
onError: (_err, { id }, context) => {
if (context?.previous) queryClient.setQueryData(projectKeys.detail(id), context.previous);
},
onSettled: (_data, _err, { id }) => queryClient.invalidateQueries({ queryKey: projectKeys.detail(id) }),
});
Simpler optimistic option for a single list: render mutation.variables as a pending
row while mutation.isPending, and skip cache surgery entirely. Use
useMutationState when the pending row renders in a different component.
Rules:
- Cancel in-flight queries for the key before writing optimistic data, or a late response overwrites it.
- Invalidate by the widest prefix that is still correct (
projectKeys.lists()), notqueryClient.invalidateQueries()with no filter. - Never put server data into Zustand/Redux “to share it”; call the same query hook in both components.
SSR and hydration
Next.js App Router pattern:
// app/projects/page.tsx (server component)
import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query";
export default async function Page() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery(projectQueries.list({ status: "active" }));
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ProjectList /> {/* client component calling useQuery with the same options */}
</HydrationBoundary>
);
}
- Create a new
QueryClientper request on the server; a module-level client leaks data between users. - On the client, create the client once (in
useState(() => new QueryClient())or a module singleton guarded for the browser). - Set
staleTimeabove 0 in the client defaults when hydrating, or every hydrated query refetches on mount. - TanStack Router and TanStack Start integrate through route loaders calling
queryClient.ensureQueryData; see thetanstack-routerskill.
Upgrading from v4
cacheTime became gcTime; isLoading for first load is isPending;
keepPreviousData: true became placeholderData: keepPreviousData; all hooks take a
single object; onSuccess/onError/onSettled were removed from useQuery (keep them
on mutations); infinite queries require initialPageParam. A codemod exists in the
@tanstack/react-query package; running it via npx needs network access.
Gotchas
useQuerycallbacksonSuccessdo not exist in v5. Put side effects in the component (an effect keyed ondata) or in the mutation that caused the change.refetchOnWindowFocussurprises in development because switching to DevTools counts as focus. Do not disable it globally to fix a dev annoyance; raisestaleTime.initialDatais treated as fresh cache data and is persisted;placeholderDatais not. Use placeholder for previews.- Retries default to 3 with exponential backoff on the client. For 4xx errors, set
retry: (count, err) => err.status >= 500 && count < 3. - Tests: create a fresh
QueryClientper test withretry: false.
Verification
- Devtools: keys match the factory shape; entries go stale and garbage-collect as configured.
- Network tab: navigating between cached views within
staleTimemakes no request; a mutation triggers exactly the invalidated refetches. - Optimistic paths: force the API to fail (devtools request blocking or MSW) and confirm rollback plus an error message.
- Tests and type-check pass.
Output
For each feature: the key factory entries added, staleTime per query with the reason, and for each mutation the keys it invalidates or updates and its rollback behavior.
What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "TanStack Query"
short_description: "Exact keys, deliberate staleTime, honest mutations"
default_prompt: "$tanstack-query Our project list refetches on every tab focus; set sensible staleTime and keys" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.