TanStack Query solves the problem most teams solve badly: server state management. Before React Query, teams used Redux or Context API for data that belonged on the server — leading to stale data, loading state logic scattered everywhere, and no cache invalidation strategy. Claude Code generates React Query patterns that eliminate these problems: correct query keys, consistent invalidation, and optimistic updates that don’t break on error.
This guide covers TanStack Query with Claude Code: query keys, caching strategies, mutations with optimistic updates, infinite scroll, and the mental model that makes it work.
Server State vs Client State
We have user preferences in Redux and user data from the API also in Redux.
Which of these belongs in React Query?
Claude Code’s answer: “User data from the API is server state — TanStack Query manages it. User preferences that only live in the browser (dark mode, sidebar open/closed) are client state — Context or Zustand manage that. Don’t put API data in Redux/Context alongside client state.”
The distinction:
- Server state: Lives on the server. Fetched async. Can be stale. Shared with other users. → TanStack Query
- Client state: Only in the browser. Synchronous. Belongs to this user’s session. → useState, Context, Zustand
Query Keys
Our app is hard to maintain because invalidating one endpoint
accidentally invalidates unrelated data. Fix the query key structure.
// Poorly structured — one flat string per query
useQuery({ queryKey: ['users'] });
useQuery({ queryKey: ['user-orders'] }); // Invalidata 'users' to refresh orders? No.
// Well-structured — hierarchical, predictable invalidation
// src/lib/query-keys.ts
export const queryKeys = {
// All user queries
users: {
all: () => ['users'] as const,
lists: () => [...queryKeys.users.all(), 'list'] as const,
list: (filters: UserFilters) => [...queryKeys.users.lists(), filters] as const,
details: () => [...queryKeys.users.all(), 'detail'] as const,
detail: (id: string) => [...queryKeys.users.details(), id] as const,
},
// User's orders — nested under the user context
orders: {
all: () => ['orders'] as const,
lists: () => [...queryKeys.orders.all(), 'list'] as const,
list: (userId: string, page: number) =>
[...queryKeys.orders.lists(), { userId, page }] as const,
detail: (id: string) => [...queryKeys.orders.all(), 'detail', id] as const,
},
} as const;
// Invalidation is now precise:
// Invalidate ALL user queries:
queryClient.invalidateQueries({ queryKey: queryKeys.users.all() });
// Invalidate only user lists (not detail pages):
queryClient.invalidateQueries({ queryKey: queryKeys.users.lists() });
// Invalidate a specific user:
queryClient.invalidateQueries({ queryKey: queryKeys.users.detail(userId) });
Custom Query Hooks
Wrap all API calls in custom hooks so components don't
import fetch/axios directly.
// src/hooks/use-users.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { queryKeys } from '../lib/query-keys';
import { api } from '../lib/api';
export function useUsers(filters: UserFilters = {}) {
return useQuery({
queryKey: queryKeys.users.list(filters),
queryFn: () => api.get('/users', { params: filters }),
staleTime: 5 * 60 * 1000, // Consider fresh for 5 min
gcTime: 10 * 60 * 1000, // Keep in cache for 10 min (formerly cacheTime)
placeholderData: (previousData) => previousData, // Keep old data while refetching
});
}
export function useUser(id: string | undefined) {
return useQuery({
queryKey: queryKeys.users.detail(id!),
queryFn: () => api.get(`/users/${id}`),
enabled: !!id, // Don't fetch if id is undefined
staleTime: 60 * 1000, // Individual user refreshes less often
});
}
export function useUpdateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<User> }) =>
api.patch(`/users/${id}`, data),
// Optimistic update
onMutate: async ({ id, data }) => {
// Cancel any in-flight queries for this user
await queryClient.cancelQueries({ queryKey: queryKeys.users.detail(id) });
// Snapshot the current value (for rollback)
const previousUser = queryClient.getQueryData(queryKeys.users.detail(id));
// Optimistically update the cache
queryClient.setQueryData(queryKeys.users.detail(id), (old: User) => ({
...old,
...data,
}));
return { previousUser };
},
onError: (err, { id }, context) => {
// Rollback to snapshot on error
if (context?.previousUser) {
queryClient.setQueryData(queryKeys.users.detail(id), context.previousUser);
}
},
onSettled: (data, error, { id }) => {
// Always refetch after mutation to ensure consistency
queryClient.invalidateQueries({ queryKey: queryKeys.users.detail(id) });
queryClient.invalidateQueries({ queryKey: queryKeys.users.lists() });
},
});
}
Infinite Scroll
The product listing page loads all 500 products at once.
Add infinite scroll with load-more button.
// src/hooks/use-products.ts
import { useInfiniteQuery } from '@tanstack/react-query';
interface ProductPage {
products: Product[];
nextCursor: string | null;
total: number;
}
export function useInfiniteProducts(filters: ProductFilters) {
return useInfiniteQuery({
queryKey: ['products', 'infinite', filters],
queryFn: ({ pageParam }) =>
api.get<ProductPage>('/products', {
params: { ...filters, cursor: pageParam, limit: 20 },
}),
initialPageParam: null as string | null,
getNextPageParam: (lastPage) => lastPage.nextCursor,
staleTime: 2 * 60 * 1000,
});
}
// src/components/ProductList.tsx
import { useInfiniteProducts } from '../hooks/use-products';
import { useIntersectionObserver } from '../hooks/use-intersection';
export function ProductList({ filters }: { filters: ProductFilters }) {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteProducts(filters);
// Intersection observer for auto-load-more
const { ref: sentinelRef } = useIntersectionObserver({
onIntersect: () => {
if (hasNextPage && !isFetchingNextPage) fetchNextPage();
},
});
const allProducts = data?.pages.flatMap(page => page.products) ?? [];
return (
<div>
<div className="product-grid">
{allProducts.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
{/* Sentinel element triggers load-more when visible */}
<div ref={sentinelRef}>
{isFetchingNextPage && <Spinner />}
{!hasNextPage && allProducts.length > 0 && (
<p>All {allProducts.length} products loaded</p>
)}
</div>
</div>
);
}
Prefetching
Users hover over product cards before clicking.
Prefetch the product detail during hover to make navigation instant.
// src/components/ProductCard.tsx
import { useQueryClient } from '@tanstack/react-query';
export function ProductCard({ product }: { product: Product }) {
const queryClient = useQueryClient();
const prefetchProduct = () => {
// Prefetch but only if not already in cache
queryClient.prefetchQuery({
queryKey: queryKeys.products.detail(product.id),
queryFn: () => api.get(`/products/${product.id}`),
staleTime: 60 * 1000, // Don't prefetch if cached within last minute
});
};
return (
<Link
to={`/products/${product.id}`}
onMouseEnter={prefetchProduct}
onFocus={prefetchProduct} // Keyboard nav too
>
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<span>${product.price}</span>
</Link>
);
}
When users click the link, the data is already in cache — the detail page renders immediately.
For connecting TanStack Query to a GraphQL backend with generated hooks, see the GraphQL codegen guide. For managing forms that submit to mutations (useMutation with react-hook-form), the tRPC guide shows the integration pattern. The Claude Skills 360 bundle includes TanStack Query skill sets for server state patterns. Start with the free tier to refactor your data fetching to TanStack Query.