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.

  • Frontend
  • $tanstack-query
  • 209 lines
  • 2 files
  • ~2,150 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- tanstack-query

Installs to ~/.agents/skills/tanstack-query/ for every project on this machine.

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

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.

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

--- name: tanstack-query description: Implements TanStack Query v5 data fetching in React — query key factories with queryOptions, staleTime and gcTime choices, dependent and paginated… license: MIT ---

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

  1. Read AGENTS.md, then check @tanstack/react-query in package.json. This skill targets v5 (object-only signatures, isPending, gcTime). For v4 code, plan the upgrade first (see below).
  2. Find the QueryClient setup and its defaultOptions. Changes to defaults affect the whole app; prefer per-query options.
  3. Find existing key conventions (grep -rn "queryKey" src | head -30) and follow them.
  4. 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 queryFn uses must be in the key. A missing variable means two different requests share one cache entry.
  • queryOptions gives type inference for useQuery, useSuspenseQuery, prefetchQuery, getQueryData and setQueryData from a single definition.
  • Pass signal to 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

DatastaleTimeNotes
Reference data (countries, plans, feature flags)Infinity or hoursinvalidate explicitly when it changes
Lists and detail views edited by the user30 s to 5 minrely on invalidation after mutations
Collaborative or live data0 to 10 s, plus refetchInterval or websockets
Data fetched on the server and hydratedabove 0otherwise 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 use skipToken as the queryFn for type-safe disabling.
  • Derive with select instead 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 with enabled.

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()), not queryClient.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 QueryClient per 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 staleTime above 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 the tanstack-router skill.

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

  • useQuery callbacks onSuccess do not exist in v5. Put side effects in the component (an effect keyed on data) or in the mutation that caused the change.
  • refetchOnWindowFocus surprises in development because switching to DevTools counts as focus. Do not disable it globally to fix a dev annoyance; raise staleTime.
  • initialData is treated as fresh cache data and is persisted; placeholderData is 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 QueryClient per test with retry: false.

Verification

  • Devtools: keys match the factory shape; entries go stale and garbage-collect as configured.
  • Network tab: navigating between cached views within staleTime makes 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.yamlgenerated from this page

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.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem