TanStack Query Advanced
Production patterns for TanStack Query v5 - server state management done right.
Overview
-
Infinite scroll / pagination
-
Optimistic UI updates
-
Prefetching for instant navigation
-
Complex cache invalidation
-
Dependent/parallel queries
-
Mutations with rollback
Core Patterns
- Infinite Queries (Cursor-Based)
import { useInfiniteQuery } from '@tanstack/react-query';
interface Page { items: Item[]; nextCursor: string | null; }
function useInfiniteItems() {
return useInfiniteQuery({
queryKey: ['items'],
queryFn: async ({ pageParam }): Promise<Page> => {
const res = await fetch(/api/items?cursor=${pageParam ?? ''});
return res.json();
},
initialPageParam: null as string | null,
getNextPageParam: (lastPage) => lastPage.nextCursor,
getPreviousPageParam: (firstPage) => firstPage.prevCursor,
});
}
// Component function ItemList() { const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteItems();
return ( <> {data?.pages.flatMap((page) => page.items.map((item) => ( <ItemCard key={item.id} item={item} /> )))} <button onClick={() => fetchNextPage()} disabled={!hasNextPage || isFetchingNextPage} > {isFetchingNextPage ? 'Loading...' : hasNextPage ? 'Load More' : 'No more'} </button> </> ); }
- Optimistic Updates
import { useMutation, useQueryClient } from '@tanstack/react-query';
function useUpdateTodo() { const queryClient = useQueryClient();
return useMutation({ mutationFn: updateTodo, onMutate: async (newTodo) => { // Cancel outgoing refetches await queryClient.cancelQueries({ queryKey: ['todos', newTodo.id] });
// Snapshot previous value
const previousTodo = queryClient.getQueryData(['todos', newTodo.id]);
// Optimistically update
queryClient.setQueryData(['todos', newTodo.id], newTodo);
// Return context for rollback
return { previousTodo };
},
onError: (err, newTodo, context) => {
// Rollback on error
queryClient.setQueryData(['todos', newTodo.id], context?.previousTodo);
},
onSettled: (data, error, variables) => {
// Always refetch after error or success
queryClient.invalidateQueries({ queryKey: ['todos', variables.id] });
},
}); }
- Prefetching Patterns
// Prefetch on hover function UserLink({ userId }: { userId: string }) { const queryClient = useQueryClient();
const prefetchUser = () => { queryClient.prefetchQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId), staleTime: 5 * 60 * 1000, // 5 minutes }); };
return (
<Link to={/users/${userId}} onMouseEnter={prefetchUser}>
View User
</Link>
);
}
// Prefetch in loader (React Router) export const loader = (queryClient: QueryClient) => async ({ params }) => { await queryClient.ensureQueryData({ queryKey: ['user', params.id], queryFn: () => fetchUser(params.id), }); return null; };
- Smart Cache Invalidation
const queryClient = useQueryClient();
// Invalidate exact query queryClient.invalidateQueries({ queryKey: ['todos', 1] });
// Invalidate all todos queries queryClient.invalidateQueries({ queryKey: ['todos'] });
// Invalidate with predicate queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0] === 'todos' && (query.queryKey[1] as Todo)?.status === 'done', });
// Invalidate and refetch immediately queryClient.refetchQueries({ queryKey: ['todos'], type: 'active' });
// Remove from cache entirely queryClient.removeQueries({ queryKey: ['todos', 1] });
- Dependent Queries
function useUserPosts(userId: string) { // First query const userQuery = useQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId), });
// Dependent query - only runs when user is loaded const postsQuery = useQuery({ queryKey: ['posts', userId], queryFn: () => fetchUserPosts(userId), enabled: !!userQuery.data, // Only fetch when user exists });
return { user: userQuery.data, posts: postsQuery.data }; }
- Parallel Queries
import { useQueries } from '@tanstack/react-query';
function useMultipleUsers(userIds: string[]) { return useQueries({ queries: userIds.map((id) => ({ queryKey: ['user', id], queryFn: () => fetchUser(id), staleTime: 5 * 60 * 1000, })), combine: (results) => ({ users: results.map((r) => r.data).filter(Boolean), pending: results.some((r) => r.isPending), error: results.find((r) => r.error)?.error, }), }); }
- Query Deduplication & Batching
// Configure in QueryClient const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 1000 * 60, // 1 minute gcTime: 1000 * 60 * 5, // 5 minutes (formerly cacheTime) refetchOnWindowFocus: false, retry: 3, retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), }, }, });
- Suspense Integration
import { useSuspenseQuery } from '@tanstack/react-query';
function UserProfile({ userId }: { userId: string }) { // This will suspend until data is ready const { data: user } = useSuspenseQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId), });
return <div>{user.name}</div>; }
// Wrap with Suspense <Suspense fallback={<Skeleton />}> <UserProfile userId="123" /> </Suspense>
- Mutation State Tracking
import { useMutationState } from '@tanstack/react-query';
function PendingTodos() { // Track all pending todo mutations const pendingMutations = useMutationState({ filters: { mutationKey: ['addTodo'], status: 'pending' }, select: (mutation) => mutation.state.variables as Todo, });
return ( <> {pendingMutations.map((todo) => ( <TodoItem key={todo.id} todo={todo} isPending /> ))} </> ); }
Configuration Best Practices
const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 1000 * 60, // Data fresh for 1 min gcTime: 1000 * 60 * 5, // Cache for 5 min refetchOnWindowFocus: true, // Refetch on tab focus refetchOnReconnect: true, // Refetch on network reconnect retry: 3, // Retry failed requests }, mutations: { retry: 1, onError: (error) => toast.error(error.message), }, }, });
Quick Reference
// ✅ Create typed query with queryOptions helper (v5) const userQueryOptions = (id: string) => queryOptions({ queryKey: ['user', id] as const, queryFn: () => fetchUser(id), staleTime: 5 * 60 * 1000, });
// ✅ Use the query options for consistency const { data } = useQuery(userQueryOptions(userId)); await queryClient.prefetchQuery(userQueryOptions(userId)); await queryClient.ensureQueryData(userQueryOptions(userId));
// ✅ v5: isPending instead of isLoading (initial load only) if (isPending) return <Skeleton />;
// ✅ v5: gcTime instead of cacheTime gcTime: 5 * 60 * 1000,
// ✅ useSuspenseQuery for Suspense integration const { data } = useSuspenseQuery(userQueryOptions(userId));
// ✅ Selective invalidation queryClient.invalidateQueries({ queryKey: ['todos'], exact: true });
// ❌ NEVER destructure useQuery result at call site const { data, isLoading } = useQuery({ queryKey: ['users'] }); // BAD - recreates object
// ❌ NEVER use string keys useQuery({ queryKey: 'users' }); // BAD - use arrays
// ❌ NEVER store server state in Zustand const useStore = create((set) => ({ users: [] })); // BAD - use React Query
Key Decisions
Decision Option A Option B Recommendation
Query key structure String Array Array - supports hierarchy and serialization
Cache timing staleTime gcTime Both - staleTime for freshness, gcTime for memory
Loading state isLoading isPending isPending (v5) - isLoading includes background refetches
Query definition Inline queryOptions queryOptions - reusable for prefetch/loader/useQuery
Suspense useQuery + loading useSuspenseQuery useSuspenseQuery for React 18+ Suspense
Optimistic updates setQueryData only setQueryData + invalidate Both - optimistic then reconcile
Parallel queries Multiple useQuery useQueries useQueries - combined loading/error state
Infinite queries Manual pagination useInfiniteQuery useInfiniteQuery - built-in cursor handling
Anti-Patterns (FORBIDDEN)
// ❌ FORBIDDEN: Storing server state in Zustand/Redux const useStore = create((set) => ({ users: [], // Server state belongs in React Query! fetchUsers: async () => { const users = await api.getUsers(); set({ users }); // Stale data, no background refetch }, }));
// ❌ FORBIDDEN: String query keys useQuery({ queryKey: 'todos', // Must be an array! queryFn: fetchTodos, });
// ❌ FORBIDDEN: Using deprecated cacheTime (v5) useQuery({ queryKey: ['todos'], cacheTime: 5 * 60 * 1000, // WRONG - use gcTime in v5 });
// ❌ FORBIDDEN: Using isLoading for initial state (v5) // isLoading = isPending && isFetching (includes background refetch) if (isLoading) return <Skeleton />; // WRONG - use isPending
// ❌ FORBIDDEN: Over-invalidating after mutations useMutation({ mutationFn: updateTodo, onSuccess: () => { queryClient.invalidateQueries(); // Invalidates EVERYTHING! }, });
// ❌ FORBIDDEN: Forgetting to cancel queries in optimistic updates useMutation({ onMutate: async (newTodo) => { // Missing: await queryClient.cancelQueries(...) const previous = queryClient.getQueryData(['todos']); queryClient.setQueryData(['todos'], (old) => [...old, newTodo]); return { previous }; }, });
// ❌ FORBIDDEN: Not returning context from onMutate useMutation({ onMutate: async (newTodo) => { const previous = queryClient.getQueryData(['todos']); queryClient.setQueryData(['todos'], (old) => [...old, newTodo]); // Missing: return { previous }; // Required for rollback! }, onError: (err, newTodo, context) => { queryClient.setQueryData(['todos'], context?.previous); // context is undefined! }, });
// ❌ FORBIDDEN: Mutating cache data directly queryClient.setQueryData(['todos'], (old) => { old.push(newTodo); // WRONG - mutates existing array return old; }); // ✅ CORRECT: Return new array queryClient.setQueryData(['todos'], (old) => [...old, newTodo]);
// ❌ FORBIDDEN: Fetching inside useEffect useEffect(() => { fetch('/api/users').then(setUsers); // Use React Query instead! }, []);
Related Skills
-
zustand-patterns
-
Client state management (use alongside React Query for server state)
-
form-state-patterns
-
Form state with React Hook Form (integrate mutation status)
-
msw-mocking
-
Mock Service Worker for testing queries without network
-
react-server-components-framework
-
RSC hydration with React Query
Capability Details
infinite-queries
Keywords: infinite, pagination, cursor, load more, scroll, pages Solves: Implementing cursor-based pagination with automatic page management
optimistic-updates
Keywords: optimistic, instant, rollback, onMutate, setQueryData, cancel Solves: Showing immediate UI feedback before server confirmation with rollback
prefetching
Keywords: prefetch, hover, preload, ensureQueryData, loader, navigation Solves: Loading data before it's needed for instant navigation
cache-invalidation
Keywords: invalidate, refetch, stale, fresh, gcTime, staleTime, exact Solves: Keeping cache in sync with server after mutations
suspense-integration
Keywords: suspense, useSuspenseQuery, streaming, fallback, boundary Solves: Integrating with React Suspense for declarative loading states
parallel-queries
Keywords: useQueries, parallel, concurrent, combine, batch Solves: Fetching multiple independent queries with combined state
References
-
references/cache-strategies.md
-
Cache invalidation patterns
-
scripts/query-hooks-template.ts
-
Production query hook template
-
checklists/tanstack-checklist.md
-
Implementation checklist
-
examples/tanstack-examples.md
-
Real-world usage examples