Claude Code with Tanstack Query: Keys, Mutations, and Cache
Why Tanstack Query needs its own CLAUDE.md section
Tanstack Query is one of the best-understood libraries in the React ecosystem. The official docs are thorough. The community patterns are well-established. Claude Code has seen a lot of Tanstack Query code.
That combination creates a false sense of security. Claude knows the API surface. It can scaffold a useQuery call, wire up a useMutation, and write an infinite scroll with useInfiniteQuery. What it cannot do without explicit project context is make three categories of decisions correctly: query key structure, cache invalidation scope, and the staleTime semantics that govern when data refetches.
These are not obscure edge cases. They are the decisions that determine whether your cache is a performance asset or a consistency liability. A string key like "users" invalidates correctly in a simple demo and catastrophically in a real application where tenant isolation matters. A mutation that calls invalidateQueries without scoping to the right key prefix silently underfetches or overfetches. A staleTime of zero means every component mount triggers a network request; a staleTime of Infinity means updates never propagate.
Claude defaults to patterns that work in the simple case. The defaults are documentable and fixable in CLAUDE.md, which is what this guide covers.
If you have not configured Claude Code for your React project yet, Claude Code with React covers the baseline setup. For the broader Next.js context this guide builds on, Claude Code with Next.js is the companion post.
The CLAUDE.md template for Tanstack Query
The CLAUDE.md section below covers the five areas where Claude produces incorrect or inconsistent output without explicit rules: query key design, queryOptions co-location, mutation invalidation, optimistic update shape, and time-based cache semantics. Copy it into your CLAUDE.md under a ## Tanstack Query header and adjust the domain keys to match your data model.
## Tanstack Query v5
### Package and version
- @tanstack/react-query 5.x (not v4, API is different, check docs before generating)
- @tanstack/react-query-devtools for development
- queryClient is created once in lib/query-client.ts, exported as a singleton
- QueryClientProvider wraps the app in app/providers.tsx
### Query key conventions (CRITICAL, read before writing any queryKey)
Query keys are arrays. Never strings. The first element is the entity domain.
All keys follow the same factory shape, never construct ad-hoc arrays.
#### Key factory, define in lib/query-keys.ts, never inline
export const queryKeys = {
users: {
all: () => ["users"] as const,
lists: () => ["users", "list"] as const,
list: (filters: UserFilters) => ["users", "list", filters] as const,
details:() => ["users", "detail"] as const,
detail: (id: string) => ["users", "detail", id] as const,
},
posts: {
all: () => ["posts"] as const,
lists: () => ["posts", "list"] as const,
list: (filters: PostFilters) => ["posts", "list", filters] as const,
details:() => ["posts", "detail"] as const,
detail: (id: string) => ["posts", "detail", id] as const,
},
} as const;
#### Key rules
- NEVER use bare strings: queryKey: ["users"] is allowed only from the factory
- NEVER inline partial keys in invalidateQueries, use factory functions
- Use queryKeys.users.all() to invalidate all user queries
- Use queryKeys.users.details() to invalidate all detail queries without touching lists
- Use queryKeys.users.detail(id) to invalidate one specific record
### queryOptions helper (v5, use this, not raw useQuery options)
Co-locate query options with the data layer, not the component.
Define in lib/queries/{entity}.queries.ts, import into components.
#### Pattern (lib/queries/posts.queries.ts)
import { queryOptions } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query-keys";
import { fetchPost, fetchPosts } from "@/lib/api/posts";
export const postListOptions = (filters: PostFilters) =>
queryOptions({
queryKey: queryKeys.posts.list(filters),
queryFn: () => fetchPosts(filters),
staleTime: 60 * 1000, // 1 minute
});
export const postDetailOptions = (id: string) =>
queryOptions({
queryKey: queryKeys.posts.detail(id),
queryFn: () => fetchPost(id),
staleTime: 5 * 60 * 1000, // 5 minutes
});
Components call: const { data } = useQuery(postDetailOptions(id));
This is the correct v5 pattern. NEVER define queryKey + queryFn inline in the component.
### Mutation rules
Every mutation must:
1. Call onSuccess with specific invalidation (not global invalidateQueries())
2. Invalidate using factory keys, never raw strings
3. Handle optimistic updates with onMutate + onError rollback when UX requires it
#### Invalidation pattern
onSuccess: async (data, variables) => {
await queryClient.invalidateQueries({ queryKey: queryKeys.posts.lists() });
await queryClient.invalidateQueries({ queryKey: queryKeys.posts.detail(variables.id) });
},
NEVER: await queryClient.invalidateQueries(); // invalidates everything
NEVER: await queryClient.invalidateQueries({ queryKey: ["posts"] }); // raw string
### Optimistic update pattern
Use only when the mutation is fast (< 300ms P99) and rollback UX is acceptable.
onMutate: async (variables) => {
await queryClient.cancelQueries({ queryKey: queryKeys.posts.detail(variables.id) });
const previous = queryClient.getQueryData(queryKeys.posts.detail(variables.id));
queryClient.setQueryData(queryKeys.posts.detail(variables.id), (old) => ({
...old,
...variables,
}));
return { previous };
},
onError: (err, variables, context) => {
if (context?.previous) {
queryClient.setQueryData(queryKeys.posts.detail(variables.id), context.previous);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.posts.details() });
},
### staleTime and gcTime semantics
staleTime = how long data is considered fresh (no background refetch on mount)
gcTime = how long inactive query data stays in memory (default 5 min)
Defaults for this project:
- List queries: staleTime: 60 * 1000 (1 min)
- Detail queries: staleTime: 5 * 60 * 1000 (5 min)
- Reference data: staleTime: Infinity (static data: countries, enums)
- User session: staleTime: 0 (always fresh, auth state matters)
Set staleTime in queryOptions, not in QueryClient defaultOptions, unless truly global.
### Suspense mode (v5)
Use useSuspenseQuery / useSuspenseInfiniteQuery for data that the UI cannot render without.
Wrap in <Suspense fallback={<Skeleton />}> and <ErrorBoundary fallback={<Error />}>.
NEVER mix useSuspenseQuery and conditional rendering (the component always has data).
### Prefetching from Next.js server components
In a Server Component or Server Action:
import { queryClient } from "@/lib/query-client"; // server-side singleton
await queryClient.prefetchQuery(postDetailOptions(id));
dehydrate(queryClient) and pass to <HydrationBoundary state={dehydratedState}>.
Use lib/get-query-client.ts (React cache wrapper) for per-request isolation, never
use a module-level singleton on the server. See Next.js Tanstack Query integration docs.
### Hard rules
- NEVER use string query keys
- NEVER skip invalidation in onSuccess
- NEVER set staleTime: Infinity on user-specific data
- NEVER use setQueryData as the only cache update (always follow with invalidation in onSettled)
- ALWAYS cancel in-flight queries before optimistic update (cancelQueries in onMutate)
- ALWAYS use queryOptions helper for shared queries
- ALWAYS co-locate queryOptions near the fetch function, not inside components
This is the full template. The sections that matter most in practice are query keys and mutation invalidation because they are the areas where Claude diverges most often and where the divergence causes real bugs rather than just style inconsistencies.
The five failure modes Claude produces without this context
Understanding why Claude fails helps you write better rules and catch generated code before it ships.
Failure 1: String query keys
The most common mistake Claude makes is writing queryKey: ["users"] as a raw array literal inline. This works for a single query. It breaks when you call invalidateQueries({ queryKey: ["users"] }) in a mutation and expect all user-related queries to invalidate, because the match is exact by default.
Claude produces this pattern because most Tanstack Query documentation and tutorials use it. The tutorials are showing the simplest example, not the production pattern. Without the factory in CLAUDE.md, Claude will generate a new raw array literal every time it writes a query, and the key shapes will drift across files.
The factory pattern fixes this by making key construction a function call. queryKeys.users.list(filters) always returns the same shape. queryKeys.users.details() invalidates every detail query regardless of which specific ID they loaded. The key hierarchy maps to your data relationships, not to the component tree.
Failure 2: Inline queryOptions
Claude writes useQuery({ queryKey: [...], queryFn: () => fetch(...) }) inline in components by default. This is the quickest way to get a working demo and the worst pattern for a real codebase. It means:
- The same query is redefined in every component that needs the data
- Changing the
staleTimefor a query requires finding every component that uses it - The query key lives in the component, not near the API call it is coupled to
- Prefetching from a Server Component requires duplicating the queryKey and queryFn
The queryOptions helper in v5 exists to solve exactly this. Defining options outside the component and importing them everywhere means one change propagates everywhere. It also unlocks type-safe prefetching from server components because the options object carries the type of the returned data.
Failure 3: Global or missing invalidation
Claude generates two kinds of bad invalidation. The first is no invalidation: useMutation({ mutationFn: createPost }) with no onSuccess. The second is over-invalidation: queryClient.invalidateQueries() with no key, which clears the entire cache on every mutation.
Neither is correct. The first means the cache never reflects the mutation result, so the UI stays stale until the user refreshes. The second means every mutation triggers a full refetch of every active query on the page, which is a network waterfall on every interaction.
The correct scope is factory-key based. A post creation should invalidate the post list (the new post needs to appear) but not the user profile (the author's data did not change). Putting the invalidation pattern in CLAUDE.md gives Claude the scope it cannot infer from the mutation alone.
Failure 4: Optimistic updates without rollback
Claude will sometimes scaffold an optimistic update using setQueryData in onMutate but omit the onError rollback and the cancelQueries call. The result looks right when mutations succeed and produces a permanently incorrect cache when they fail. The user sees a change appear, gets an error toast, and the change stays in the UI.
The canonical three-callback pattern is onMutate (cancel in-flight, snapshot, apply optimistic update) plus onError (restore snapshot) plus onSettled (invalidate to confirm actual server state). All three are required. Missing any one of them breaks the contract.
Failure 5: staleTime and gcTime confusion
Claude frequently conflates these two values. staleTime controls when a query transitions from fresh to stale, which triggers a background refetch the next time a component mounts that uses the query. gcTime (formerly cacheTime in v4) controls how long unused query data stays in memory before being garbage collected.
A staleTime of zero means every mount triggers a network request. This is the default and it is appropriate for data that changes frequently, like a live feed or a notification count. It is inappropriate for data that rarely changes, like a list of product categories. A staleTime of Infinity means the data never goes stale and will only refresh on explicit invalidation.
The defaults in the CLAUDE.md template above are conservative but practical: one minute for lists, five minutes for detail records, infinity for reference data that is seeded once and does not change between deployments. You can override per-query. The point is to have a documented default so Claude does not generate staleTime: 0 everywhere.
Code patterns: queryOptions, suspense, mutations, and prefetching
The CLAUDE.md template above defines the rules. These patterns show what correct implementation looks like in each of the four main Tanstack Query workflows.
Pattern 1: queryOptions co-located with the fetch function
This is the baseline pattern for every data type. Define the options near the API function, export them, and import them in components and server components alike.
// lib/queries/posts.queries.ts
import { queryOptions, infiniteQueryOptions } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query-keys";
import { api } from "@/lib/api-client";
export type PostFilters = {
status?: "draft" | "published";
authorId?: string;
page?: number;
};
export const postListOptions = (filters: PostFilters = {}) =>
queryOptions({
queryKey: queryKeys.posts.list(filters),
queryFn: () => api.posts.list(filters),
staleTime: 60 * 1000,
});
export const postDetailOptions = (id: string) =>
queryOptions({
queryKey: queryKeys.posts.detail(id),
queryFn: () => api.posts.detail(id),
staleTime: 5 * 60 * 1000,
});
export const postInfiniteOptions = (filters: Omit<PostFilters, "page">) =>
infiniteQueryOptions({
queryKey: queryKeys.posts.list({ ...filters, page: "infinite" }),
queryFn: ({ pageParam }) => api.posts.list({ ...filters, page: pageParam }),
initialPageParam: 1,
getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined,
staleTime: 60 * 1000,
});
// components/posts/post-detail.tsx
import { useQuery } from "@tanstack/react-query";
import { postDetailOptions } from "@/lib/queries/posts.queries";
export function PostDetail({ id }: { id: string }) {
const { data, isPending, isError } = useQuery(postDetailOptions(id));
if (isPending) return <PostSkeleton />;
if (isError) return <ErrorMessage />;
return <Post post={data} />;
}
The component does not know the query key, the fetch function, or the stale time. It knows only the options object. If you change the stale time in posts.queries.ts, every component that uses postDetailOptions picks it up without touching any component file.
Pattern 2: Mutation with optimistic update and rollback
This pattern covers the full three-callback shape. It is verbose, and every line is necessary. Claude will generate the onMutate body and omit the onError rollback roughly half the time without the canonical pattern in CLAUDE.md.
// hooks/mutations/use-update-post.ts
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query-keys";
import { api } from "@/lib/api-client";
import type { Post } from "@/types";
type UpdatePostVars = {
id: string;
data: Partial<Pick<Post, "title" | "body" | "status">>;
};
export function useUpdatePost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: UpdatePostVars) => api.posts.update(id, data),
onMutate: async ({ id, data }) => {
// 1. Cancel any in-flight requests for this post (avoid race conditions)
await queryClient.cancelQueries({ queryKey: queryKeys.posts.detail(id) });
// 2. Snapshot current value for rollback
const previous = queryClient.getQueryData<Post>(queryKeys.posts.detail(id));
// 3. Apply optimistic update immediately
queryClient.setQueryData<Post>(queryKeys.posts.detail(id), (old) =>
old ? { ...old, ...data } : old,
);
// 4. Return snapshot in context for onError
return { previous };
},
onError: (_err, { id }, context) => {
// Restore snapshot on failure
if (context?.previous) {
queryClient.setQueryData(queryKeys.posts.detail(id), context.previous);
}
},
onSuccess: async (_data, { id }) => {
// Invalidate related list queries so the list reflects the update
await queryClient.invalidateQueries({ queryKey: queryKeys.posts.lists() });
},
onSettled: (_data, _err, { id }) => {
// Always confirm actual server state after settle (success or failure)
queryClient.invalidateQueries({ queryKey: queryKeys.posts.detail(id) });
},
});
}
Three things to note. First, cancelQueries runs before setQueryData. Without it, an in-flight request that completes after the optimistic update will overwrite the optimistic state with stale server data. Second, onSettled runs on both success and failure, which means the detail query always gets a fresh fetch to confirm server state regardless of what happened. Third, onSuccess invalidates the list queries separately, not in onSettled, so the invalidation targets are precise and the queries refetch only what changed.
Pattern 3: Suspense query with error boundary
Suspense mode in Tanstack Query v5 is first-class. useSuspenseQuery throws a promise (for Suspense) or an error (for ErrorBoundary), which means the component body always runs with data. No isPending or isError checks inside the component.
// components/posts/post-detail-suspense.tsx
import { useSuspenseQuery } from "@tanstack/react-query";
import { postDetailOptions } from "@/lib/queries/posts.queries";
// This component always has data, no loading or error states inside it
export function PostDetailSuspense({ id }: { id: string }) {
const { data } = useSuspenseQuery(postDetailOptions(id));
return <Post post={data} />;
}
// app/posts/[id]/page.tsx (or parent layout)
import { Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { PostDetailSuspense } from "@/components/posts/post-detail-suspense";
import { PostSkeleton } from "@/components/posts/post-skeleton";
import { PostError } from "@/components/posts/post-error";
export default function PostPage({ params }: { params: { id: string } }) {
return (
<ErrorBoundary fallback={<PostError />}>
<Suspense fallback={<PostSkeleton />}>
<PostDetailSuspense id={params.id} />
</Suspense>
</ErrorBoundary>
);
}
The rule is simple: useSuspenseQuery and useSuspenseInfiniteQuery must live inside a <Suspense> boundary with an error boundary above it. Claude will generate one or the other but rarely both when it inlines them. The pattern in CLAUDE.md ensures both wrappers are present.
Do not mix useSuspenseQuery with conditional rendering like if (!data) return null. The query always resolves before the component renders, so that branch is unreachable and signals a misunderstanding of how suspense works.
Pattern 4: Prefetching from a Next.js server component
Tanstack Query's Next.js integration supports prefetching on the server and hydrating the cache on the client. The critical implementation detail is the getQueryClient helper, which uses React's cache() function to give each server request its own QueryClient instance.
// lib/get-query-client.ts
import { QueryClient } from "@tanstack/react-query";
import { cache } from "react";
export const getQueryClient = cache(() => new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
},
},
}));
// app/posts/[id]/page.tsx (server component)
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { getQueryClient } from "@/lib/get-query-client";
import { postDetailOptions } from "@/lib/queries/posts.queries";
import { PostDetailSuspense } from "@/components/posts/post-detail-suspense";
export default async function PostPage({ params }: { params: { id: string } }) {
const queryClient = getQueryClient();
// Prefetch runs on the server, no network request from the client on first load
await queryClient.prefetchQuery(postDetailOptions(params.id));
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Suspense fallback={<PostSkeleton />}>
<PostDetailSuspense id={params.id} />
</Suspense>
</HydrationBoundary>
);
}
The reason getQueryClient uses cache() instead of a module-level singleton is request isolation. A module-level QueryClient on the server is shared across all concurrent requests, which means one user's data can leak into another user's response. React.cache() scopes the instance to the current request, which is the correct behaviour for a per-request cache.
Claude will sometimes generate a module-level QueryClient singleton for the server because that is the simplest way to make TypeScript happy. The CLAUDE.md rule and the explicit helper file prevent this.
Deciding between Suspense mode and traditional loading state
Both approaches are correct in different contexts. The question is what you want the component to own.
Traditional useQuery with isPending and isError checks is the right choice when:
- The component has a meaningful partial render state (some data loads fast, some loads slow)
- You want granular control over loading UI at the component level
- You are working in a codebase that does not have error boundaries wired up
- The query is dependent on user interaction rather than the initial page load
useSuspenseQuery is the right choice when:
- The component cannot render anything useful without the data
- You want to colocate loading and error UI at the route or layout level rather than in each component
- You are prefetching data from a server component (the query is already cached, suspense resolves immediately on the client)
- You have multiple parallel data needs and want them to load together under one Suspense boundary
In a Next.js App Router project, the typical pattern is: prefetch on the server, wrap in HydrationBoundary, use useSuspenseQuery in the component. The query is already in the cache when the component renders on the client, so the suspense resolves instantly and there is no loading flash.
For data that loads after user interaction (filtering, pagination, form submissions) or that depends on client-side state, traditional useQuery with explicit loading states often gives you better control.
Add this decision to CLAUDE.md as a rule: "Use useSuspenseQuery for server-prefetched data and initial page loads. Use useQuery with explicit isPending checks for interaction-triggered queries."
Integrating with Zod and TypeScript for type-safe queries
The queryOptions pattern composes naturally with Zod validation on the API response. Validating at the queryFn boundary means every component that reads from the cache gets a type-safe, runtime-validated value. This is the same philosophy as the Claude Code with Zod patterns applied to your data layer.
// lib/api/posts.ts
import { z } from "zod";
export const PostSchema = z.object({
id: z.string().uuid(),
title: z.string().min(1),
body: z.string(),
status: z.enum(["draft", "published"]),
authorId: z.string().uuid(),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
});
export type Post = z.infer<typeof PostSchema>;
export const PostListSchema = z.object({
posts: z.array(PostSchema),
total: z.number(),
nextPage: z.number().nullable(),
});
export async function fetchPost(id: string): Promise<Post> {
const res = await fetch(`/api/posts/${id}`);
if (!res.ok) throw new Error(`Failed to fetch post ${id}`);
return PostSchema.parse(await res.json());
}
Now postDetailOptions has a fully inferred return type because fetchPost returns Post. The component's data is typed correctly without any type assertions, and if the API returns a shape that does not match the schema, the error surfaces at the query level rather than as a runtime crash in a component.
For global state that lives outside server state, Claude Code with Zustand covers the client-state side of the same stack. Tanstack Query for server state plus Zustand for UI state is a common and clean separation in 2026 React projects.
What Claude gets right without context
Not everything needs a CLAUDE.md rule. Claude handles several Tanstack Query patterns reliably.
Dependent queries with the enabled option work correctly. Claude generates useQuery({ ...options, enabled: !!userId }) without prompting and understands that a disabled query does not fetch.
The select option for data transformation is generated correctly. Claude uses it to transform or subset the cached data without affecting the cache itself.
Polling with refetchInterval is scaffolded correctly. Claude sets it on the query options and does not confuse it with staleTime.
useIsMutating and useIsFetching for global loading indicators are used correctly when requested.
keepPreviousData (now placeholderData: keepPreviousData in v5) for pagination is scaffolded correctly when the context makes it clear you are paginating.
The areas where Claude needs the CLAUDE.md guidance are specific: key design, invalidation scope, optimistic rollback, staleTime defaults, and the server component prefetch pattern. Fix those five areas and Claude's Tanstack Query output is reliable.
Connecting the data layer to the rest of the stack
Tanstack Query sits in the middle of your stack. It talks to your API layer below and your components above. Getting it right has downstream effects on both.
On the API side, the Claude Code with TypeScript patterns for typed fetch wrappers and the Zod integration above give you end-to-end type safety from the API response to the component render. On the component side, the React patterns in Claude Code with React for component composition and the Next.js patterns in Claude Code with Next.js for server components and data fetching compose directly with the prefetch pattern above.
The CLAUDE.md template in this guide is designed to sit alongside your other framework rules, not replace them. Add it to an existing CLAUDE.md under a ## Tanstack Query section. It adds approximately 50 lines and prevents the class of cache bugs that are hardest to debug: stale data that looks correct, mutations that appear to succeed but do not propagate, and invalidation that silently over-fetches or under-fetches.
Claudify includes a Tanstack Query CLAUDE.md template alongside configurations for Next.js, TypeScript, Zod, Zustand, and a dozen other libraries in the modern React stack.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify