TanStack Query Core Knowledge
Deep Knowledge: Use mcp__documentation__fetch_docs with technology: tanstack-query for comprehensive documentation.
Setup
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 1000 * 60, // 1 minute retry: 1, }, }, });
function App() { return ( <QueryClientProvider client={queryClient}> <MyApp /> </QueryClientProvider> ); }
Queries
import { useQuery } from '@tanstack/react-query';
function UserProfile({ userId }: { userId: string }) { const { data, isLoading, error } = useQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId), });
if (isLoading) return <Spinner />; if (error) return <Error message={error.message} />; return <div>{data.name}</div>; }
// With options const { data } = useQuery({ queryKey: ['users', { status: 'active' }], queryFn: () => fetchUsers({ status: 'active' }), staleTime: 5 * 60 * 1000, refetchOnWindowFocus: false, enabled: !!userId, // Conditional fetch });
Mutations
import { useMutation, useQueryClient } from '@tanstack/react-query';
function CreateUser() { const queryClient = useQueryClient();
const mutation = useMutation({ mutationFn: (newUser: CreateUserInput) => createUser(newUser), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['users'] }); }, });
return ( <form onSubmit={(e) => { e.preventDefault(); mutation.mutate({ name: 'John' }); }}> <button disabled={mutation.isPending}> {mutation.isPending ? 'Creating...' : 'Create'} </button> </form> ); }
Optimistic Updates
const mutation = useMutation({ mutationFn: updateUser, onMutate: async (newData) => { await queryClient.cancelQueries({ queryKey: ['user', id] }); const previous = queryClient.getQueryData(['user', id]); queryClient.setQueryData(['user', id], newData); return { previous }; }, onError: (err, newData, context) => { queryClient.setQueryData(['user', id], context.previous); }, onSettled: () => { queryClient.invalidateQueries({ queryKey: ['user', id] }); }, });
When NOT to Use This Skill
Scenario Use Instead
Client-side UI state (modals, form inputs) zustand or React state
Vue 3 applications pinia with custom composables
Static configuration data React Context API
Real-time WebSocket data Custom hooks with WebSocket + Zustand
GraphQL queries @apollo/client or urql
Anti-Patterns
Anti-Pattern Why It's Bad Correct Approach
Using queries for client state Unnecessary complexity, wrong abstraction Use Zustand or React state
Not using query keys properly Cache collisions, wrong data Use query key factory pattern
Fetching on every render Performance issues Set proper staleTime and gcTime
Manual cache invalidation everywhere Hard to maintain Use mutation's onSuccess with invalidateQueries
Ignoring loading/error states Poor UX Always handle isLoading and error
Using refetch() instead of invalidate()
Bypasses cache, wastes requests Use invalidateQueries() for revalidation
Not prefetching predictable navigation Slow perceived performance Prefetch on hover/mount
Storing queries in global state Defeats TanStack Query purpose Let TanStack Query manage cache
No retry strategy for transient errors Failed requests on network blips Configure retry with backoff
Missing query key dependencies Stale data when params change Include all variables in query key
Quick Troubleshooting
Issue Cause Solution
Queries not refetching staleTime too high or enabled: false
Lower staleTime or check enabled condition
"No QueryClient set" error Missing QueryClientProvider
Wrap app with <QueryClientProvider client={queryClient}>
Mutations not updating UI Not invalidating queries Call queryClient.invalidateQueries() in onSuccess
Infinite refetch loop Query key changes on every render Stabilize query key with useMemo or constants
SSR hydration mismatch Server/client data out of sync Use HydrationBoundary with dehydrated state
Memory leaks Queries never garbage collected Set proper gcTime (default 5 minutes)
Optimistic update reverted incorrectly Context not returned from onMutate
Return previous value from onMutate , restore in onError
Multiple identical requests No deduplication interval Set dedupingInterval in query options
Production Readiness
Query Client Configuration
// lib/queryClient.ts import { QueryClient, QueryCache, MutationCache } from '@tanstack/react-query';
export const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error, query) => {
// Global error handling for queries
if (query.state.data !== undefined) {
// Only show error toasts for background refetch failures
toast.error(Background update failed: ${error.message});
}
// Log to monitoring
logError({ type: 'query', key: query.queryKey, error });
},
}), mutationCache: new MutationCache({ onError: (error, _variables, _context, mutation) => { logError({ type: 'mutation', key: mutation.options.mutationKey, error }); }, }), defaultOptions: { queries: { staleTime: 1000 * 60, // 1 minute gcTime: 1000 * 60 * 5, // 5 minutes (formerly cacheTime) retry: (failureCount, error) => { // Don't retry on 4xx errors if (error.status >= 400 && error.status < 500) return false; return failureCount < 3; }, refetchOnWindowFocus: process.env.NODE_ENV === 'production', }, mutations: { retry: false, }, }, });
Query Key Factory
// lib/queryKeys.ts - Organized query keys export const queryKeys = { 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, }, posts: { all: ['posts'] as const, byUser: (userId: string) => [...queryKeys.posts.all, 'user', userId] as const, }, } as const;
// Usage const { data } = useQuery({ queryKey: queryKeys.users.detail(userId), queryFn: () => fetchUser(userId), });
// Invalidate all user queries queryClient.invalidateQueries({ queryKey: queryKeys.users.all });
Error Handling
// hooks/useApiQuery.ts import { UseQueryOptions, useQuery } from '@tanstack/react-query';
interface ApiError { status: number; message: string; }
export function useApiQuery<TData>( options: UseQueryOptions<TData, ApiError> ) { return useQuery({ ...options, throwOnError: (error) => { // Only throw for critical errors that should trigger error boundary return error.status >= 500; }, }); }
// With error boundary function UserProfile({ userId }: { userId: string }) { const { data, error, isLoading } = useApiQuery({ queryKey: queryKeys.users.detail(userId), queryFn: () => fetchUser(userId), });
if (isLoading) return <Skeleton />;
if (error) { if (error.status === 404) return <NotFound />; return <ErrorMessage message={error.message} />; }
return <UserCard user={data} />; }
Prefetching & SSR
// Next.js App Router example // app/users/page.tsx import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
export default async function UsersPage() { const queryClient = new QueryClient();
await queryClient.prefetchQuery({ queryKey: queryKeys.users.lists(), queryFn: fetchUsers, });
return ( <HydrationBoundary state={dehydrate(queryClient)}> <UserList /> </HydrationBoundary> ); }
// Prefetch on hover function UserLink({ userId }: { userId: string }) { const queryClient = useQueryClient();
const prefetchUser = () => { queryClient.prefetchQuery({ queryKey: queryKeys.users.detail(userId), queryFn: () => fetchUser(userId), staleTime: 1000 * 60, }); };
return (
<Link href={/users/${userId}} onMouseEnter={prefetchUser}>
View User
</Link>
);
}
Testing
// test-utils.tsx import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const createTestQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false }, }, });
export function renderWithQuery(ui: React.ReactElement) { const queryClient = createTestQueryClient(); return { ...render( <QueryClientProvider client={queryClient}> {ui} </QueryClientProvider> ), queryClient, }; }
// Test example test('displays user data', async () => { server.use( http.get('/api/users/:id', () => { return HttpResponse.json({ id: '1', name: 'John' }); }) );
renderWithQuery(<UserProfile userId="1" />);
expect(await screen.findByText('John')).toBeInTheDocument(); });
Monitoring Metrics
Metric Target
Cache hit ratio
80%
Average query time < 200ms
Stale data served < 5%
Background refetch errors < 1%
Checklist
-
Query key factory pattern
-
Global error handling in QueryCache
-
Retry logic for transient failures
-
staleTime and gcTime configured
-
Optimistic updates for mutations
-
Prefetching for navigation
-
SSR hydration setup
-
Test utilities with fresh QueryClient
-
DevTools in development only
-
Error boundaries for critical failures
OpenAPI Integration
TanStack Query works seamlessly with generated OpenAPI clients for type-safe data fetching.
With Generated Types
import type { paths, components } from '@/types/api'; import { useQuery, useMutation } from '@tanstack/react-query';
type User = components['schemas']['User']; type CreateUserInput = components['schemas']['CreateUserInput'];
// Type-safe query
function useUser(id: string) {
return useQuery<User>({
queryKey: ['users', id],
queryFn: async () => {
const res = await fetch(/api/users/${id});
return res.json();
},
});
}
// Type-safe mutation function useCreateUser() { return useMutation<User, Error, CreateUserInput>({ mutationFn: async (input) => { const res = await fetch('/api/users', { method: 'POST', body: JSON.stringify(input), }); return res.json(); }, }); }
With openapi-fetch
import createClient from 'openapi-fetch'; import type { paths } from '@/types/api';
const api = createClient<paths>({ baseUrl: '/api' });
function useUsers() { return useQuery({ queryKey: ['users'], queryFn: async () => { const { data, error } = await api.GET('/users'); if (error) throw error; return data; }, }); }
Related Skills
Skill Purpose
OpenAPI Codegen Generate types from spec
HTTP Clients Axios/fetch integration
React API Alternative patterns (SWR)
Reference Documentation
-
Query Keys
-
Caching Strategies
-
OpenAPI Types