TanStack Query manages server state in React applications — it handles caching, background refetching, loading states, and synchronization between tabs automatically. useQuery({ queryKey, queryFn }) fetches and caches data; useMutation({ mutationFn }) handles writes. Optimistic updates via onMutate update the cache instantly while the request is in-flight; onError rolls back if the mutation fails. useInfiniteQuery enables infinite scroll with fetchNextPage and getNextPageParam. queryClient.prefetchQuery populates the cache before navigation. Dependent queries with enabled: !!userId fire only when their dependencies resolve. Suspense mode integrates with React Suspense boundaries. persistQueryClient with createAsyncStoragePersister implements offline support. Claude Code generates TanStack Query hook compositions, mutation configurations with optimistic updates, infinite query patterns, and the query client setup for production React applications.
CLAUDE.md for TanStack Query
## TanStack Query Stack
- Version: @tanstack/react-query >= 5.0, @tanstack/react-query-devtools >= 5.0
- Keys: ["orders", customerId, { status, page }] — hierarchical, invalidate by prefix
- Mutation: useMutation({ mutationFn, onMutate, onError, onSettled }) — optimistic
- Infinite: useInfiniteQuery({ queryFn: ({pageParam}) => fetch, getNextPageParam })
- Prefetch: queryClient.prefetchQuery — populate cache before navigation
- Suspense: useSuspenseQuery / useSuspenseInfiniteQuery + <Suspense fallback>
- Stale: staleTime controls background refetch — 0 = always refetch in background
- Persist: persistQueryClient + createAsyncStoragePersister — offline cache
Query Client Setup
// lib/query-client.ts — shared QueryClient configuration
import { QueryClient } from "@tanstack/react-query"
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister"
import { persistQueryClient } from "@tanstack/react-query-persist-client"
export function createQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute before background refetch
gcTime: 10 * 60 * 1000, // 10 minutes in GC before eviction
retry: (failureCount, error: any) => {
// Don't retry on 4xx client errors
if (error?.status >= 400 && error?.status < 500) return false
return failureCount < 3
},
refetchOnWindowFocus: false, // Don't refetch on tab switch
refetchOnReconnect: "always",
},
mutations: {
retry: 1,
},
},
})
}
// Offline persistence with IndexedDB
export function setupPersistence(queryClient: QueryClient) {
const persister = createAsyncStoragePersister({
storage: window.indexedDB
? {
getItem: async (key) => JSON.parse(localStorage.getItem(key) ?? "null"),
setItem: async (key, value) => localStorage.setItem(key, JSON.stringify(value)),
removeItem: async (key) => localStorage.removeItem(key),
}
: undefined,
throttleTime: 5_000,
})
persistQueryClient({
queryClient,
persister,
maxAge: 24 * 60 * 60 * 1000, // 24 hours
buster: process.env.NEXT_PUBLIC_BUILD_ID ?? "dev",
})
}
Query Key Factory
// lib/query-keys.ts — centralized query key factory
const ordersKeys = {
all: ["orders"] as const,
lists: () => [...ordersKeys.all, "list"] as const,
list: (filters: { customerId?: string; status?: string; page?: number }) =>
[...ordersKeys.lists(), filters] as const,
details: () => [...ordersKeys.all, "detail"] as const,
detail: (id: string) => [...ordersKeys.details(), id] as const,
}
export const queryKeys = {
orders: ordersKeys,
products: {
all: ["products"] as const,
detail: (id: string) => ["products", "detail", id] as const,
list: (filters: Record<string, unknown>) => ["products", "list", filters] as const,
},
user: {
current: ["user", "me"] as const,
profile: (id: string) => ["user", "profile", id] as const,
},
}
// Usage: queryClient.invalidateQueries({ queryKey: queryKeys.orders.all })
// Invalidates ALL order queries (list and detail)
Optimistic Updates
// hooks/useOrders.ts — mutations with optimistic updates
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { queryKeys } from "@/lib/query-keys"
import { cancelOrder, updateOrderStatus } from "@/api/orders"
import type { Order } from "@/types"
export function useCancelOrder() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (orderId: string) => cancelOrder(orderId),
// Optimistic update: immediately mark as cancelled in cache
onMutate: async (orderId) => {
// Cancel in-flight refetches that may overwrite our optimistic update
await queryClient.cancelQueries({ queryKey: queryKeys.orders.all })
// Snapshot the current value for rollback
const previousOrders = queryClient.getQueryData<{ orders: Order[] }>(
queryKeys.orders.lists()
)
// Optimistically update the list cache
queryClient.setQueryData(
queryKeys.orders.lists(),
(old: { orders: Order[] } | undefined) => ({
...old,
orders: old?.orders.map(o =>
o.id === orderId ? { ...o, status: "cancelled" as const } : o
) ?? [],
})
)
// Also update the detail cache if it exists
queryClient.setQueryData(
queryKeys.orders.detail(orderId),
(old: Order | undefined) =>
old ? { ...old, status: "cancelled" as const } : old
)
// Return context for rollback
return { previousOrders, orderId }
},
// Rollback on error
onError: (_err, orderId, context) => {
if (context?.previousOrders) {
queryClient.setQueryData(queryKeys.orders.lists(), context.previousOrders)
}
queryClient.setQueryData(queryKeys.orders.detail(orderId), (old: Order | undefined) =>
old ? { ...old, status: "pending" as const } : old
)
},
// Always refetch after settlement to sync with server
onSettled: (_data, _err, orderId) => {
queryClient.invalidateQueries({ queryKey: queryKeys.orders.all })
},
})
}
Infinite Scroll
// hooks/useInfiniteOrders.ts — infinite query
import { useInfiniteQuery } from "@tanstack/react-query"
import { queryKeys } from "@/lib/query-keys"
import { fetchOrders } from "@/api/orders"
export function useInfiniteOrders({
customerId,
status,
}: {
customerId: string
status?: string
}) {
return useInfiniteQuery({
queryKey: queryKeys.orders.list({ customerId, status }),
queryFn: async ({ pageParam }) => {
return fetchOrders({
customerId,
status,
cursor: pageParam as string | undefined,
limit: 20,
})
},
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
staleTime: 30_000,
})
}
// Usage in component
function OrdersList({ customerId }: { customerId: string }) {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
} = useInfiniteOrders({ customerId })
// Flatten pages into a single array
const orders = data?.pages.flatMap(page => page.orders) ?? []
// Intersection Observer for automatic load-more
const loadMoreRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const observer = new IntersectionObserver(
entries => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage()
}
},
{ threshold: 0.1 }
)
if (loadMoreRef.current) observer.observe(loadMoreRef.current)
return () => observer.disconnect()
}, [hasNextPage, isFetchingNextPage, fetchNextPage])
return (
<div>
{orders.map(order => <OrderCard key={order.id} order={order} />)}
<div ref={loadMoreRef} className="py-4 text-center">
{isFetchingNextPage && <Spinner />}
{!hasNextPage && orders.length > 0 && (
<p className="text-muted-foreground">All orders loaded</p>
)}
</div>
</div>
)
}
Suspense Mode
// components/orders/OrderDetail.tsx — with Suspense
import { useSuspenseQuery } from "@tanstack/react-query"
import { Suspense } from "react"
import { ErrorBoundary } from "react-error-boundary"
import { queryKeys } from "@/lib/query-keys"
import { fetchOrder } from "@/api/orders"
function OrderDetailContent({ orderId }: { orderId: string }) {
// Throws promise while loading (Suspense), throws error on failure (ErrorBoundary)
const { data: order } = useSuspenseQuery({
queryKey: queryKeys.orders.detail(orderId),
queryFn: () => fetchOrder(orderId),
})
return (
<div>
<h1>Order #{order.id.slice(-8)}</h1>
<p>Status: {order.status}</p>
<p>Total: ${(order.totalCents / 100).toFixed(2)}</p>
</div>
)
}
export function OrderDetail({ orderId }: { orderId: string }) {
return (
<ErrorBoundary fallback={<p>Failed to load order</p>}>
<Suspense fallback={<OrderDetailSkeleton />}>
<OrderDetailContent orderId={orderId} />
</Suspense>
</ErrorBoundary>
)
}
Prefetching
// app/orders/page.tsx — Next.js with prefetching
import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query"
import { queryKeys } from "@/lib/query-keys"
import { fetchOrders } from "@/api/orders"
import { OrdersList } from "./OrdersList"
export default async function OrdersPage({ searchParams }: any) {
const queryClient = new QueryClient()
// Prefetch on server — populates the cache before hydration
await queryClient.prefetchInfiniteQuery({
queryKey: queryKeys.orders.list({ customerId: searchParams.customerId }),
queryFn: ({ pageParam }) => fetchOrders({ cursor: pageParam as any, limit: 20 }),
initialPageParam: undefined,
})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<OrdersList customerId={searchParams.customerId} />
</HydrationBoundary>
)
}
For the SWR alternative from Vercel that provides similar data fetching with a simpler API and closer integration with Next.js — particularly for the Next.js unstable_serialize SSR pattern and useSWRInfinite for pagination, see the Next.js data fetching guide for comparison. For the TanStack Router integration that uses loader functions for data fetching at route level — combining routing and query prefetching with automatic suspense, see the TanStack Router guide for loader + TanStack Query patterns. The Claude Skills 360 bundle includes TanStack Query skill sets covering optimistic updates, infinite scroll, and suspense patterns. Start with the free tier to try query hook generation.