← All posts
·13 min read

Claude Code with React Query: Server State, Cached Correctly

Claude CodeReact QueryTanStack QueryData Fetching
Claude Code with React Query: Server state managed correctly

Why React Query without CLAUDE.md generates cache bugs

React Query (officially TanStack Query since v4, but still widely searched as "react query") is the dominant server-state library for React in 2026. It handles request deduplication, background refetching, cache invalidation, retries, optimistic updates, and pagination. The mental model is simple: server state is not client state, and useQuery + useMutation cover 95% of the API surface most apps need.

The problem is that Claude Code does not know which patterns produce a correct cache and which silently corrupt it. By default, Claude generates React Query code that compiles and renders but ships subtle bugs: query keys structured as strings instead of arrays (which breaks the partial-match invalidation pattern), mutations that do not invalidate related queries (which shows stale data after an update), optimistic updates with no rollback (which leaves the UI in a wrong state when the mutation fails), and v4 patterns against a v5 package (which silently degrades because deprecated options are ignored).

The v4 to v5 migration (released October 2023, now the default in 2026) compounds this. v5 renamed several options, removed callbacks from useQuery, restructured the QueryClient configuration, and changed the API for useMutation. Claude's training data mixes both versions, and the cross-version drift produces bugs that look like React Query is misbehaving when actually the code is partially wrong.

This guide covers the CLAUDE.md configuration that locks Claude Code into React Query's correct model: structured array query keys, mutation flows that invalidate related caches, optimistic updates with rollback, suspense and error boundary integration, and the v5 patterns that replace deprecated v4 forms. For the React component layer queries plug into, Claude Code with React covers hook composition. For Next.js Server Components integration, Claude Code with Next.js covers the hydration pattern that pairs with React Query.

The React Query CLAUDE.md template

# React Query rules

## Stack
- @tanstack/react-query ^5.x (v5, NOT v4)
- @tanstack/react-query-devtools ^5.x for dev
- React 18.x with Suspense and ErrorBoundary
- TypeScript 5.x strict mode

## Project structure
- src/lib/query-client.ts      QueryClient singleton config
- src/lib/api/                 Per-resource API client functions
- src/lib/query-keys.ts        Centralised query key factories
- src/hooks/use-*.ts           useQuery / useMutation wrappers per resource

## Query key rules (CRITICAL)
- ALL query keys are ARRAYS, NEVER strings:
  CORRECT: useQuery({ queryKey: ['user', userId], queryFn: ... })
  WRONG:   useQuery({ queryKey: `user-${userId}`, queryFn: ... })

- Keys go from general to specific:
  ['users']                    All users list
  ['users', { filter }]        Filtered users list
  ['user', userId]             Single user
  ['user', userId, 'posts']    User's posts

- Use a query key factory for type safety:
  export const userKeys = {
    all: ['users'] as const,
    lists: () => [...userKeys.all, 'list'] as const,
    list: (filters: string) => [...userKeys.lists(), { filters }] as const,
    details: () => [...userKeys.all, 'detail'] as const,
    detail: (id: string) => [...userKeys.details(), id] as const,
  };

## Mutation rules
- ALWAYS invalidate related queries in onSuccess:
  onSuccess: () => queryClient.invalidateQueries({ queryKey: userKeys.all })
- Optimistic updates: pair onMutate + onError for rollback
- mutateAsync for await/async chains, mutate for fire-and-forget

## V5 API (NOT v4)
- useQuery: { queryKey, queryFn, ... } object form ONLY
- useMutation: { mutationFn, ... } object form ONLY
- NO onSuccess/onError CALLBACKS on useQuery (removed in v5)
  Use queryClient.setQueryData or useEffect for side effects
- isLoading renamed to isPending in v5
- cacheTime renamed to gcTime in v5

## Hard rules
- NEVER use string query keys
- NEVER mutate without invalidating affected queries
- NEVER do optimistic updates without onError rollback
- NEVER use onSuccess callback on useQuery (v4 pattern, removed in v5)
- NEVER use isLoading (v4), use isPending (v5)
- NEVER use cacheTime (v4), use gcTime (v5)
- ALWAYS use a QueryClient singleton, not a new instance per render

The array query keys rule is the most important. React Query uses structural equality on query keys to determine cache hits, dependent refetches, and selective invalidation. Array keys let invalidateQueries({ queryKey: ['users'] }) match every query starting with ['users', ...]. String keys ("user-123") cannot be partial-matched, so invalidation becomes brittle.

The v5 lock-in rule prevents a class of "code that looks right but ignores half its options" bugs. v4's onSuccess callback on useQuery is silently ignored in v5. v4's isLoading is false in v5 (replaced by isPending). v4's cacheTime: 5 * 60 * 1000 is ignored in v5 (use gcTime). Claude's training spans both versions, and without explicit instruction it regresses to v4 forms.

The invalidation rule prevents stale-data bugs. After a mutation that creates, updates, or deletes a resource, the corresponding queries hold stale data until the next background refetch (which may be minutes away). Explicit invalidateQueries in onSuccess triggers an immediate refetch, keeping the UI fresh.

Install and QueryClient setup

Install React Query:

npm i @tanstack/react-query
npm i -D @tanstack/react-query-devtools

Create the QueryClient singleton:

// src/lib/query-client.ts
import { QueryClient } from '@tanstack/react-query';

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60 * 1000,
      gcTime: 5 * 60 * 1000,
      refetchOnWindowFocus: false,
      retry: (failureCount, error) => {
        if (error instanceof Response && error.status >= 400 && error.status < 500) {
          return false;
        }
        return failureCount < 3;
      },
    },
    mutations: {
      retry: false,
    },
  },
});

The four defaults to tune:

Option Recommendation Why
staleTime 60s for most data Background refetch only after 60s of staleness
gcTime 5 minutes Garbage collect cached data after 5min of no observers
refetchOnWindowFocus false (most apps) Aggressive refetches on tab focus are usually noise
retry Skip 4xx, retry 5xx Don't retry validation errors, do retry network errors

Provide the client at your app root:

// src/app/providers.tsx (Next.js App Router)
'use client';

import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { queryClient } from '@/lib/query-client';

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

Use it in your layout:

// src/app/layout.tsx
import { Providers } from './providers';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

Add the singleton rule to CLAUDE.md:

## QueryClient singleton (ENFORCE)
- The only QueryClient lives at src/lib/query-client.ts
- Provider at src/app/providers.tsx (Next.js) or App.tsx (Vite)
- defaultOptions: staleTime 60s, gcTime 5min, refetchOnWindowFocus false
- retry: skip 4xx, retry 5xx up to 3 times
- ReactQueryDevtools enabled in dev mode
- NEVER instantiate new QueryClient() inside a component

Query key factories

The query key factory pattern centralises every key in one place. This prevents typos, enables type-safe invalidation, and documents the cache hierarchy.

// src/lib/query-keys.ts
export const userKeys = {
  all: ['users'] as const,
  lists: () => [...userKeys.all, 'list'] as const,
  list: (filters: { role?: string }) => [...userKeys.lists(), filters] as const,
  details: () => [...userKeys.all, 'detail'] as const,
  detail: (id: string) => [...userKeys.details(), id] as const,
};

export const postKeys = {
  all: ['posts'] as const,
  lists: () => [...postKeys.all, 'list'] as const,
  list: (userId: string) => [...postKeys.lists(), { userId }] as const,
  details: () => [...postKeys.all, 'detail'] as const,
  detail: (id: string) => [...postKeys.details(), id] as const,
};

Use the factories in queries and mutations:

import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
import { userKeys } from '@/lib/query-keys';
import { fetchUser, updateUser } from '@/lib/api/users';

function useUser(userId: string) {
  return useQuery({
    queryKey: userKeys.detail(userId),
    queryFn: () => fetchUser(userId),
  });
}

function useUpdateUser() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: updateUser,
    onSuccess: (updatedUser) => {
      queryClient.setQueryData(userKeys.detail(updatedUser.id), updatedUser);
      queryClient.invalidateQueries({ queryKey: userKeys.lists() });
    },
  });
}

The mutation does two things on success. First, it sets the cache for the updated user directly (avoiding a refetch for that detail view). Second, it invalidates all user list queries (because the updated user's data may appear in those lists).

Add factory patterns to CLAUDE.md:

## Query key factory pattern

- One factory per resource in src/lib/query-keys.ts
- Factories return readonly arrays with `as const`
- Hierarchical: all → lists → list(filter) → details → detail(id)
- Invalidate by hierarchy:
  - invalidateQueries({ queryKey: userKeys.all }) invalidates everything user-related
  - invalidateQueries({ queryKey: userKeys.lists() }) invalidates list queries only
  - invalidateQueries({ queryKey: userKeys.detail(id) }) invalidates one user
- NEVER write inline keys: queryKey: ['users', userId] is OK for trivial cases
  but factories are mandatory for resources used in 3+ places

useQuery patterns

The basic useQuery call:

import { useQuery } from '@tanstack/react-query';
import { userKeys } from '@/lib/query-keys';
import { fetchUser } from '@/lib/api/users';

function UserProfile({ userId }: { userId: string }) {
  const { data: user, isPending, isError, error } = useQuery({
    queryKey: userKeys.detail(userId),
    queryFn: () => fetchUser(userId),
  });

  if (isPending) return <div>Loading...</div>;
  if (isError) return <div>Error: {error.message}</div>;

  return <div>{user.name}</div>;
}

The v5 destructuring uses isPending (was isLoading in v4) and isError (unchanged). The data is undefined until the query resolves, so narrow with isPending/isError before using it.

Conditional queries (only fetch when a parameter is available):

function useUserPosts(userId: string | null) {
  return useQuery({
    queryKey: userId ? postKeys.list(userId) : ['posts', 'disabled'],
    queryFn: () => fetchUserPosts(userId!),
    enabled: userId !== null,
  });
}

The enabled: userId !== null flag prevents the query from running when userId is null. The queryKey still needs to be a valid array, hence the ['posts', 'disabled'] fallback.

Dependent queries (one query depends on another's result):

function UserAndPosts({ userId }: { userId: string }) {
  const userQuery = useQuery({
    queryKey: userKeys.detail(userId),
    queryFn: () => fetchUser(userId),
  });

  const postsQuery = useQuery({
    queryKey: postKeys.list(userQuery.data?.organizationId ?? ''),
    queryFn: () => fetchOrgPosts(userQuery.data!.organizationId),
    enabled: userQuery.data?.organizationId !== undefined,
  });

  if (userQuery.isPending) return <div>Loading user...</div>;
  if (postsQuery.isPending) return <div>Loading posts...</div>;

  return (
    <div>
      <h1>{userQuery.data.name}</h1>
      <ul>
        {postsQuery.data?.map(post => <li key={post.id}>{post.title}</li>)}
      </ul>
    </div>
  );
}

The posts query waits for the user query's data before becoming enabled. React Query automatically tracks dependencies through the enabled flag.

Add query patterns to CLAUDE.md:

## useQuery patterns

- Destructure: { data, isPending, isError, error }
- Use isPending (v5), NOT isLoading (v4)
- Conditional fetch: enabled: someCondition
- Dependent queries: enabled: parentQuery.data !== undefined
- NEVER use isLoading destructuring
- NEVER use onSuccess/onError options on useQuery (v4 only, removed in v5)
- For side effects: useEffect on data, or queryClient.setQueryData inline

useMutation patterns

Basic mutation with cache invalidation:

import { useMutation, useQueryClient } from '@tanstack/react-query';
import { userKeys } from '@/lib/query-keys';
import { createUser } from '@/lib/api/users';

function useCreateUser() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: createUser,
    onSuccess: (newUser) => {
      queryClient.invalidateQueries({ queryKey: userKeys.lists() });
    },
    onError: (error) => {
      console.error('User creation failed:', error);
    },
  });
}

// Usage in a component
function NewUserForm() {
  const { mutate, isPending, isError } = useCreateUser();

  return (
    <form onSubmit={(e) => {
      e.preventDefault();
      const formData = new FormData(e.currentTarget);
      mutate({
        name: formData.get('name') as string,
        email: formData.get('email') as string,
      });
    }}>
      <input name="name" required />
      <input name="email" type="email" required />
      <button type="submit" disabled={isPending}>
        {isPending ? 'Creating...' : 'Create'}
      </button>
      {isError && <p>Failed to create user</p>}
    </form>
  );
}

mutate vs mutateAsync: mutate is fire-and-forget (no return value, errors caught by onError). mutateAsync returns a promise so you can await it and catch errors in the surrounding code.

const { mutateAsync } = useCreateUser();

async function handleSubmit() {
  try {
    const newUser = await mutateAsync({ name, email });
    router.push(`/users/${newUser.id}`);
  } catch (err) {
    showToast('Failed to create user');
  }
}

Use mutateAsync when the next action depends on the mutation result (like navigation after creation). Use mutate for fire-and-forget cases like a "save" button that does not navigate.

Optimistic updates with rollback

Optimistic updates make the UI feel instant by updating the cache before the server responds. The pattern requires three callbacks: onMutate (apply the optimistic update), onError (rollback), onSettled (refetch to reconcile).

import { useMutation, useQueryClient } from '@tanstack/react-query';
import { userKeys } from '@/lib/query-keys';
import { updateUserName } from '@/lib/api/users';

function useUpdateUserName(userId: string) {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: ({ name }: { name: string }) => updateUserName(userId, name),

    onMutate: async ({ name }) => {
      await queryClient.cancelQueries({ queryKey: userKeys.detail(userId) });

      const previousUser = queryClient.getQueryData(userKeys.detail(userId));

      queryClient.setQueryData(userKeys.detail(userId), (old: any) => ({
        ...old,
        name,
      }));

      return { previousUser };
    },

    onError: (err, variables, context) => {
      if (context?.previousUser) {
        queryClient.setQueryData(userKeys.detail(userId), context.previousUser);
      }
    },

    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: userKeys.detail(userId) });
    },
  });
}

The flow:

  1. onMutate cancels any in-flight refetches for this query (so the optimistic update is not overwritten).
  2. Captures the previous data into a context object.
  3. Sets the optimistic data in the cache.
  4. Returns the context to the next callbacks.
  5. onError reads the context and restores the previous data.
  6. onSettled invalidates the query to force a refetch and reconcile with the server.

Skipping onError is the most common mistake. Without rollback, a failed mutation leaves the UI showing the wrong data permanently (until the next refetch). Add the optimistic update rule to CLAUDE.md:

## Optimistic updates (MANDATORY pattern when used)

Required callbacks for every optimistic update:
1. onMutate:
   - await queryClient.cancelQueries({ queryKey: ... })
   - const previousData = queryClient.getQueryData(queryKey)
   - queryClient.setQueryData(queryKey, optimistic value)
   - return { previousData }
2. onError:
   - if (context?.previousData) queryClient.setQueryData(queryKey, context.previousData)
3. onSettled:
   - queryClient.invalidateQueries({ queryKey: ... })

NEVER do optimistic updates without onError rollback.
NEVER skip onSettled (refetch reconciles state with server).

Suspense integration

React Query v5 includes useSuspenseQuery for Suspense boundary integration. This eliminates the manual isPending check and lets Suspense handle the loading state.

import { useSuspenseQuery } from '@tanstack/react-query';
import { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { userKeys } from '@/lib/query-keys';
import { fetchUser } from '@/lib/api/users';

function UserProfile({ userId }: { userId: string }) {
  const { data: user } = useSuspenseQuery({
    queryKey: userKeys.detail(userId),
    queryFn: () => fetchUser(userId),
  });

  // user is guaranteed defined here
  return <div>{user.name}</div>;
}

function App({ userId }: { userId: string }) {
  return (
    <ErrorBoundary fallback={<div>Failed to load user</div>}>
      <Suspense fallback={<div>Loading user...</div>}>
        <UserProfile userId={userId} />
      </Suspense>
    </ErrorBoundary>
  );
}

useSuspenseQuery suspends the component while the query is pending and throws to the nearest error boundary on failure. The result is cleaner component code at the cost of requiring Suspense and ErrorBoundary in the tree.

Common Claude Code mistakes with React Query

Six patterns Claude generates incorrectly without CLAUDE.md constraints.

1. String query keys

Claude generates: useQuery({ queryKey: 'user-' + userId, queryFn: ... }).

Correct pattern: useQuery({ queryKey: ['user', userId], queryFn: ... }).

2. v4 onSuccess callback on useQuery

Claude generates: useQuery({ queryKey: [...], queryFn: ..., onSuccess: data => { ... } }).

Correct pattern: v5 removed this. Use useEffect on data or set cache from a mutation.

3. isLoading instead of isPending

Claude generates: const { isLoading } = useQuery({ ... }).

Correct pattern: const { isPending } = useQuery({ ... }).

4. cacheTime instead of gcTime

Claude generates: useQuery({ ..., cacheTime: 5 * 60 * 1000 }).

Correct pattern: useQuery({ ..., gcTime: 5 * 60 * 1000 }).

5. Mutation with no invalidation

Claude generates: useMutation({ mutationFn: updateUser }) with no onSuccess.

Correct pattern: useMutation({ mutationFn: updateUser, onSuccess: () => queryClient.invalidateQueries({ queryKey: userKeys.all }) }).

6. Optimistic update without rollback

Claude generates: onMutate that updates the cache but no onError to restore on failure.

Correct pattern: onMutate captures previous data, onError restores, onSettled invalidates.

Add these as concrete before/after blocks in CLAUDE.md.

Server Component hydration

For Next.js App Router, you can prefetch queries on the server and hydrate them on the client:

// src/app/users/[id]/page.tsx
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
import { userKeys } from '@/lib/query-keys';
import { fetchUser } from '@/lib/api/users';
import { UserProfile } from './user-profile';

export default async function UserPage({ params }: { params: { id: string } }) {
  const queryClient = new QueryClient();

  await queryClient.prefetchQuery({
    queryKey: userKeys.detail(params.id),
    queryFn: () => fetchUser(params.id),
  });

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <UserProfile userId={params.id} />
    </HydrationBoundary>
  );
}

The server prefetches the query, dehydrates it into the React tree, and the client picks up the cache state on hydration. The UserProfile client component calls useQuery and finds the cached data immediately, no loading state visible.

Add hydration to CLAUDE.md:

## Server Component prefetch + hydrate

- Server: const queryClient = new QueryClient(); await queryClient.prefetchQuery(...)
- Wrap client tree: <HydrationBoundary state={dehydrate(queryClient)}>
- Client component: useQuery picks up the prefetched data
- IMPORTANT: queryKey on server prefetch MUST exactly match queryKey on client useQuery
- Use the same query key factory on both sides to guarantee match

For broader Next.js integration patterns that work alongside React Query, Claude Code with Next.js covers the App Router request lifecycle.

Permission hooks for React Query workflows

{
  "permissions": {
    "allow": [
      "Bash(npm i @tanstack/react-query*)",
      "Bash(node scripts/check-query-keys.js*)"
    ],
    "deny": [
      "Bash(node scripts/clear-cache.js*)"
    ]
  }
}

Building React Query integrations that cache correctly

The React Query CLAUDE.md in this guide produces React applications where query keys are always arrays with a centralised factory, mutations invalidate related queries explicitly, optimistic updates always pair with rollback, v5 patterns replace v4 leftovers, and Suspense integration is available where the team wants it.

The underlying principle: React Query gives you a powerful cache, but powerful caches have failure modes that look like correct code until you debug them. Claude has no signal about which patterns produce the right cache behaviour without explicit instruction. Every rule you skip becomes a stale-data bug or a rollback failure in production.

For the surrounding data layer choices, Claude Code with Drizzle covers the type-safe ORM that pairs well with React Query on the client, and Claudify includes a React Query-specific CLAUDE.md template with query key factories, mutation patterns, optimistic updates, and all six common-mistake rules pre-configured.

Get Claudify. Ship React Query code that invalidates predictably and rolls back safely.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir Claudify - Featured on Startup Fame