Claude Code for TanStack Query: Server State, Caching, and Mutations — Claude Skills 360 Blog
Blog / Development / Claude Code for TanStack Query: Server State, Caching, and Mutations
Development

Claude Code for TanStack Query: Server State, Caching, and Mutations

Published: July 7, 2026
Read time: 8 min read
By: Claude Skills 360

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.

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free