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
Reference Documentation