Agent skill
tanstack-query
TanStack Query (React Query) for asynchronous server-state management with automatic caching, background refetching, optimistic updates, and pagination in React applications.
Install this agent skill to your Project
npx add-skill https://github.com/bobmatnyc/claude-mpm-skills/tree/main/toolchains/typescript/state/tanstack-query
SKILL.md
TanStack Query (React Query) Skill
Summary
TanStack Query (formerly React Query) is a powerful asynchronous state management library for React that handles server-state fetching, caching, synchronization, and updates. It eliminates the need for manual data fetching boilerplate and provides built-in features like background refetching, optimistic updates, pagination, and intelligent cache management.
When to Use
Use TanStack Query when:
- Fetching data from REST APIs, GraphQL, or tRPC endpoints
- Need automatic background refetching and cache invalidation
- Building real-time dashboards with polling or websocket data
- Implementing infinite scroll or pagination
- Require optimistic UI updates for mutations
- Managing complex server-state synchronization
- Need offline support with cache persistence
- Building applications with frequent data updates
TanStack Query excels at:
- Server-state management (API data, external state)
- Request deduplication and caching
- Stale-while-revalidate patterns
- Loading and error state management
- Prefetching and eager loading
- Parallel and dependent query orchestration
Avoid TanStack Query for:
- Pure client-side state (use Zustand, Jotai, or Context)
- Form state management (use React Hook Form, Formik)
- Simple one-time fetches without caching needs
Quick Start
Installation
npm install @tanstack/react-query
# DevTools (optional but recommended)
npm install @tanstack/react-query-devtools
Basic Setup
// app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useState } from 'react';
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
refetchOnWindowFocus: false,
},
},
}));
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
First Query
// components/UserProfile.tsx
import { useQuery } from '@tanstack/react-query';
interface User {
id: number;
name: string;
email: string;
}
function UserProfile({ userId }: { userId: number }) {
const { data, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: async () => {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) throw new Error('Failed to fetch user');
return response.json() as Promise<User>;
},
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<h1>{data.name}</h1>
<p>{data.email}</p>
</div>
);
}
First Mutation
// components/CreateUserForm.tsx
import { useMutation, useQueryClient } from '@tanstack/react-query';
function CreateUserForm() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: async (newUser: { name: string; email: string }) => {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newUser),
});
return response.json();
},
onSuccess: () => {
// Invalidate and refetch users list
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
mutation.mutate({
name: formData.get('name') as string,
email: formData.get('email') as string,
});
};
return (
<form onSubmit={handleSubmit}>
<input name="name" placeholder="Name" required />
<input name="email" type="email" placeholder="Email" required />
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Creating...' : 'Create User'}
</button>
{mutation.isError && <p>Error: {mutation.error.message}</p>}
</form>
);
}
Core Concepts
Server State vs Client State
Server State Characteristics:
- Persisted remotely (database, API, cloud)
- Requires asynchronous APIs for fetching/updating
- Can be out of sync with client
- Can be updated by other users/systems
- Examples: User data, posts, products, settings
Client State Characteristics:
- Persisted locally (memory, localStorage)
- Synchronously accessible
- Fully controlled by client
- Examples: UI theme, modal open/closed, form inputs
TanStack Query manages server state. Use Zustand/Context for client state.
Query Keys
Query keys uniquely identify queries and their cached data.
Key Structure:
// String key (simple)
queryKey: ['todos']
// Array key (recommended for dependencies)
queryKey: ['todo', todoId]
queryKey: ['todos', { status: 'active', page: 1 }]
// Nested arrays (complex hierarchies)
queryKey: ['users', userId, 'posts', { sort: 'date' }]
Key Matching:
// Exact match
queryClient.invalidateQueries({ queryKey: ['todos', 1], exact: true });
// Prefix match (invalidates all matching)
queryClient.invalidateQueries({ queryKey: ['todos'] }); // Matches ['todos', 1], ['todos', 2], etc.
// Predicate match
queryClient.invalidateQueries({
predicate: (query) => query.queryKey[0] === 'todos' && query.state.data?.status === 'draft'
});
Best Practices:
- Use arrays with hierarchical structure:
['resource', id, 'subresource'] - Place variables at the end:
['users', { filter, sort }] - Consistent ordering across components
- Use objects for complex parameters
Query Lifecycle
FRESH → STALE → INACTIVE → GARBAGE COLLECTED
↓ ↓ ↓ ↓
0ms staleTime no observers cacheTime
States:
- Fresh: Data is considered up-to-date (within
staleTime) - Stale: Data might be outdated, will refetch on trigger
- Inactive: No components using the query
- Garbage Collected: Removed from cache after
cacheTime
Configuration:
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
staleTime: 5 * 60 * 1000, // 5 minutes (data fresh)
gcTime: 10 * 60 * 1000, // 10 minutes (cache retention)
refetchOnWindowFocus: true, // Refetch when window regains focus
refetchOnReconnect: true, // Refetch when reconnecting
refetchInterval: 30000, // Poll every 30 seconds
});
Cache Behavior
Automatic Caching:
// First component - triggers fetch
function ComponentA() {
const { data } = useQuery({ queryKey: ['user', 1], queryFn: fetchUser });
return <div>{data?.name}</div>;
}
// Second component - uses cache instantly
function ComponentB() {
const { data } = useQuery({ queryKey: ['user', 1], queryFn: fetchUser });
return <div>{data?.email}</div>; // No second fetch!
}
Stale-While-Revalidate:
// Shows cached data immediately, refetches in background if stale
const { data, isRefetching } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
staleTime: 60000, // Fresh for 1 minute
});
// data available from cache immediately
// isRefetching = true if background refetch happening
Queries
useQuery Hook
Basic Syntax:
const {
data, // Query result
error, // Error object if failed
isLoading, // First load (no cached data)
isFetching, // Any fetch (including background)
isSuccess, // Query succeeded
isError, // Query failed
status, // 'pending' | 'error' | 'success'
fetchStatus, // 'fetching' | 'paused' | 'idle'
refetch, // Manual refetch function
} = useQuery({
queryKey: ['key'],
queryFn: async () => { /* fetch logic */ },
});
Query Function Patterns
Basic Fetch:
const { data } = useQuery({
queryKey: ['users'],
queryFn: async () => {
const response = await fetch('/api/users');
if (!response.ok) throw new Error('Network error');
return response.json();
},
});
Query Key in Function:
const { data } = useQuery({
queryKey: ['user', userId],
queryFn: async ({ queryKey }) => {
const [_key, userId] = queryKey;
const response = await fetch(`/api/users/${userId}`);
return response.json();
},
});
Abort Signal (Cancellation):
const { data } = useQuery({
queryKey: ['todos'],
queryFn: async ({ signal }) => {
const response = await fetch('/api/todos', { signal });
return response.json();
},
});
// Automatically cancels on unmount or when query becomes inactive
Axios Pattern:
import axios from 'axios';
const { data } = useQuery({
queryKey: ['repos', username],
queryFn: ({ signal }) =>
axios.get(`/api/repos/${username}`, { signal }).then(res => res.data),
});
Dependent Queries
Sequential Queries:
// Wait for user before fetching projects
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
const { data: projects } = useQuery({
queryKey: ['projects', user?.id],
queryFn: () => fetchProjects(user!.id),
enabled: !!user, // Only run when user exists
});
Conditional Queries:
const { data } = useQuery({
queryKey: ['premium-features', userId],
queryFn: fetchPremiumFeatures,
enabled: user?.isPremium === true, // Only fetch for premium users
});
Parallel Queries
Manual Parallel:
function Dashboard() {
const users = useQuery({ queryKey: ['users'], queryFn: fetchUsers });
const posts = useQuery({ queryKey: ['posts'], queryFn: fetchPosts });
const projects = useQuery({ queryKey: ['projects'], queryFn: fetchProjects });
if (users.isLoading || posts.isLoading || projects.isLoading) {
return <Spinner />;
}
return <div>/* render dashboard */</div>;
}
useQueries (Dynamic Parallel):
import { useQueries } from '@tanstack/react-query';
function MultiUserProfiles({ userIds }: { userIds: number[] }) {
const results = useQueries({
queries: userIds.map(id => ({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
staleTime: 60000,
})),
});
const allLoaded = results.every(r => r.isSuccess);
if (!allLoaded) return <Spinner />;
return (
<div>
{results.map((result, i) => (
<UserCard key={userIds[i]} user={result.data} />
))}
</div>
);
}
Query Placeholders
Placeholder Data (Instant UI):
const { data } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
placeholderData: [], // Show empty array while loading
});
// Dynamic placeholder from cache
const { data } = useQuery({
queryKey: ['todo', id],
queryFn: () => fetchTodo(id),
placeholderData: () => {
// Use cached list to find placeholder
return queryClient
.getQueryData(['todos'])
?.find(d => d.id === id);
},
});
Initial Data (Hydration):
const { data } = useQuery({
queryKey: ['todo', id],
queryFn: () => fetchTodo(id),
initialData: () => {
return queryClient
.getQueryData(['todos'])
?.find(d => d.id === id);
},
initialDataUpdatedAt: () =>
queryClient.getQueryState(['todos'])?.dataUpdatedAt,
});
Difference:
placeholderData: Not persisted to cache, purely UIinitialData: Persisted to cache as real data
Mutations
useMutation Hook
Basic Mutation:
const mutation = useMutation({
mutationFn: async (newTodo: Todo) => {
const response = await fetch('/api/todos', {
method: 'POST',
body: JSON.stringify(newTodo),
});
return response.json();
},
onSuccess: (data) => {
console.log('Created:', data);
},
onError: (error) => {
console.error('Failed:', error);
},
});
// Trigger mutation
mutation.mutate({ title: 'New Todo', done: false });
// Async/await variant
try {
const data = await mutation.mutateAsync(newTodo);
console.log(data);
} catch (error) {
console.error(error);
}
Mutation State:
const {
mutate, // Trigger function
mutateAsync, // Promise variant
data, // Result from successful mutation
error, // Error from failed mutation
isPending, // Mutation in progress
isSuccess, // Mutation succeeded
isError, // Mutation failed
reset, // Reset mutation state
} = useMutation({ /* ... */ });
Cache Invalidation
Invalidate Queries After Mutation:
const mutation = useMutation({
mutationFn: createTodo,
onSuccess: () => {
// Refetch all 'todos' queries
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
Multiple Invalidations:
const mutation = useMutation({
mutationFn: updateUser,
onSuccess: (data, variables) => {
// Invalidate multiple query families
queryClient.invalidateQueries({ queryKey: ['user', variables.id] });
queryClient.invalidateQueries({ queryKey: ['users'] });
queryClient.invalidateQueries({ queryKey: ['teams', data.teamId] });
},
});
Selective Invalidation:
// Only invalidate specific queries
queryClient.invalidateQueries({
queryKey: ['todos'],
exact: true, // Only ['todos'], not ['todos', 1]
});
// Predicate-based invalidation
queryClient.invalidateQueries({
predicate: (query) =>
query.queryKey[0] === 'todos' &&
query.state.data?.status === 'draft',
});
Manual Cache Updates
setQueryData (Direct Update):
const mutation = useMutation({
mutationFn: updateTodo,
onSuccess: (updatedTodo) => {
// Update specific todo in cache
queryClient.setQueryData(
['todo', updatedTodo.id],
updatedTodo
);
// Update todo in list
queryClient.setQueryData(['todos'], (old: Todo[] = []) =>
old.map(todo =>
todo.id === updatedTodo.id ? updatedTodo : todo
)
);
},
});
Immutable Updates:
// Add to list
queryClient.setQueryData(['todos'], (old: Todo[] = []) =>
[...old, newTodo]
);
// Remove from list
queryClient.setQueryData(['todos'], (old: Todo[] = []) =>
old.filter(todo => todo.id !== deletedId)
);
// Update in list
queryClient.setQueryData(['todos'], (old: Todo[] = []) =>
old.map(todo => todo.id === id ? { ...todo, ...updates } : todo)
);
Optimistic Updates
Basic Optimistic Update
const mutation = useMutation({
mutationFn: updateTodo,
// Before mutation executes
onMutate: async (newTodo) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['todos'] });
// Snapshot previous value
const previousTodos = queryClient.getQueryData(['todos']);
// Optimistically update cache
queryClient.setQueryData(['todos'], (old: Todo[] = []) =>
old.map(todo => todo.id === newTodo.id ? newTodo : todo)
);
// Return context with snapshot
return { previousTodos };
},
// On error, rollback
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context?.previousTodos);
},
// Always refetch after success or error
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
Complex Optimistic Update Pattern
interface Todo {
id: number;
title: string;
done: boolean;
}
const useUpdateTodo = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (updatedTodo: Todo) => {
const response = await fetch(`/api/todos/${updatedTodo.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedTodo),
});
if (!response.ok) throw new Error('Update failed');
return response.json();
},
onMutate: async (updatedTodo) => {
// Cancel queries to prevent race conditions
await queryClient.cancelQueries({ queryKey: ['todos'] });
await queryClient.cancelQueries({ queryKey: ['todo', updatedTodo.id] });
// Snapshot current state
const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);
const previousTodo = queryClient.getQueryData<Todo>(['todo', updatedTodo.id]);
// Optimistically update list
if (previousTodos) {
queryClient.setQueryData<Todo[]>(['todos'], (old = []) =>
old.map(todo => todo.id === updatedTodo.id ? updatedTodo : todo)
);
}
// Optimistically update detail
queryClient.setQueryData(['todo', updatedTodo.id], updatedTodo);
return { previousTodos, previousTodo };
},
onError: (err, updatedTodo, context) => {
// Rollback on error
if (context?.previousTodos) {
queryClient.setQueryData(['todos'], context.previousTodos);
}
if (context?.previousTodo) {
queryClient.setQueryData(['todo', updatedTodo.id], context.previousTodo);
}
},
onSettled: (data, error, variables) => {
// Always refetch to ensure sync
queryClient.invalidateQueries({ queryKey: ['todos'] });
queryClient.invalidateQueries({ queryKey: ['todo', variables.id] });
},
});
};
// Usage
function TodoItem({ todo }: { todo: Todo }) {
const updateTodo = useUpdateTodo();
const toggleDone = () => {
updateTodo.mutate({ ...todo, done: !todo.done });
};
return (
<div>
<input
type="checkbox"
checked={todo.done}
onChange={toggleDone}
disabled={updateTodo.isPending}
/>
{todo.title}
</div>
);
}
Pagination
useInfiniteQuery (Infinite Scroll)
Basic Infinite Query:
import { useInfiniteQuery } from '@tanstack/react-query';
interface PostsResponse {
posts: Post[];
nextCursor?: number;
}
function InfinitePosts() {
const {
data,
error,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: async ({ pageParam = 0 }) => {
const response = await fetch(`/api/posts?cursor=${pageParam}`);
return response.json() as Promise<PostsResponse>;
},
getNextPageParam: (lastPage) => lastPage.nextCursor,
initialPageParam: 0,
});
return (
<div>
{data?.pages.map((page, i) => (
<div key={i}>
{page.posts.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
))}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage
? 'Loading more...'
: hasNextPage
? 'Load More'
: 'Nothing more to load'}
</button>
</div>
);
}
Bi-directional Pagination:
const {
data,
fetchNextPage,
fetchPreviousPage,
hasNextPage,
hasPreviousPage,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: async ({ pageParam = 0 }) => {
const response = await fetch(`/api/posts?cursor=${pageParam}`);
return response.json();
},
getNextPageParam: (lastPage) => lastPage.nextCursor,
getPreviousPageParam: (firstPage) => firstPage.prevCursor,
initialPageParam: 0,
});
Infinite Scroll with Intersection Observer:
import { useInfiniteQuery } from '@tanstack/react-query';
import { useInView } from 'react-intersection-observer';
import { useEffect } from 'react';
function AutoLoadPosts() {
const { ref, inView } = useInView();
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
getNextPageParam: (lastPage) => lastPage.nextCursor,
initialPageParam: 0,
});
// Auto-fetch when sentinel comes into view
useEffect(() => {
if (inView && hasNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, fetchNextPage]);
return (
<div>
{data?.pages.map((page, i) => (
<div key={i}>
{page.posts.map(post => <PostCard key={post.id} post={post} />)}
</div>
))}
{/* Sentinel element */}
<div ref={ref}>
{isFetchingNextPage && <Spinner />}
</div>
</div>
);
}
Traditional Pagination
Page-Based Pagination:
function PaginatedPosts() {
const [page, setPage] = useState(1);
const { data, isLoading } = useQuery({
queryKey: ['posts', page],
queryFn: () => fetchPosts(page),
placeholderData: (previousData) => previousData, // Keep previous data while loading
});
return (
<div>
{isLoading ? (
<Spinner />
) : (
<div>
{data.posts.map(post => <PostCard key={post.id} post={post} />)}
</div>
)}
<div>
<button
onClick={() => setPage(old => Math.max(old - 1, 1))}
disabled={page === 1}
>
Previous
</button>
<span>Page {page}</span>
<button
onClick={() => setPage(old => old + 1)}
disabled={!data?.hasMore}
>
Next
</button>
</div>
</div>
);
}
Prefetch Next Page:
function PaginatedPosts() {
const queryClient = useQueryClient();
const [page, setPage] = useState(1);
const { data } = useQuery({
queryKey: ['posts', page],
queryFn: () => fetchPosts(page),
});
// Prefetch next page
useEffect(() => {
if (data?.hasMore) {
queryClient.prefetchQuery({
queryKey: ['posts', page + 1],
queryFn: () => fetchPosts(page + 1),
});
}
}, [data, page, queryClient]);
return (
<div>
{/* ... */}
</div>
);
}
Cache Management
Query Client Methods
getQueryData (Read Cache):
const todos = queryClient.getQueryData<Todo[]>(['todos']);
const user = queryClient.getQueryData<User>(['user', userId]);
setQueryData (Write Cache):
queryClient.setQueryData(['user', 1], newUser);
// Updater function
queryClient.setQueryData<Todo[]>(['todos'], (old = []) => [...old, newTodo]);
invalidateQueries (Mark Stale + Refetch):
// Invalidate all queries
queryClient.invalidateQueries();
// Invalidate by key prefix
queryClient.invalidateQueries({ queryKey: ['todos'] });
// Exact match only
queryClient.invalidateQueries({ queryKey: ['todos'], exact: true });
// With refetch control
queryClient.invalidateQueries({
queryKey: ['todos'],
refetchType: 'active', // 'active' | 'inactive' | 'all' | 'none'
});
refetchQueries (Immediate Refetch):
// Refetch all active queries
await queryClient.refetchQueries();
// Refetch specific queries
await queryClient.refetchQueries({ queryKey: ['todos'] });
// Refetch with filters
await queryClient.refetchQueries({
queryKey: ['todos'],
type: 'active', // Only refetch active queries
});
removeQueries (Delete from Cache):
// Remove all queries
queryClient.removeQueries();
// Remove specific
queryClient.removeQueries({ queryKey: ['todos', 1] });
// Remove with predicate
queryClient.removeQueries({
predicate: (query) =>
query.queryKey[0] === 'todos' &&
query.state.data?.isArchived === true,
});
resetQueries (Reset to Initial State):
// Reset all queries
queryClient.resetQueries();
// Reset specific
queryClient.resetQueries({ queryKey: ['todos'] });
Cache Configuration
Global Defaults:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
gcTime: 5 * 60 * 1000, // 5 minutes (formerly cacheTime)
refetchOnWindowFocus: false,
refetchOnReconnect: true,
retry: 3,
retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
},
mutations: {
retry: 1,
},
},
});
Per-Query Configuration:
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
staleTime: Infinity, // Never mark stale
gcTime: Infinity, // Never garbage collect
refetchInterval: 5000, // Refetch every 5s
refetchIntervalInBackground: false, // Don't refetch when tab inactive
});
Cache Persistence
Persist to LocalStorage:
import { QueryClient } from '@tanstack/react-query';
import { persistQueryClient } from '@tanstack/react-query-persist-client';
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 60 * 60 * 24, // 24 hours
},
},
});
const persister = createSyncStoragePersister({
storage: window.localStorage,
});
persistQueryClient({
queryClient,
persister,
maxAge: 1000 * 60 * 60 * 24, // 24 hours
});
IndexedDB Persistence:
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';
import { get, set, del } from 'idb-keyval';
const persister = createAsyncStoragePersister({
storage: {
getItem: async (key) => await get(key),
setItem: async (key, value) => await set(key, value),
removeItem: async (key) => await del(key),
},
});
Error Handling and Retry
Error Handling
Query Error Boundaries:
import { QueryErrorResetBoundary } from '@tanstack/react-query';
import { ErrorBoundary } from 'react-error-boundary';
function App() {
return (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
fallbackRender={({ error, resetErrorBoundary }) => (
<div>
<p>Error: {error.message}</p>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
)}
>
<Component />
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
);
}
// Component throws errors to boundary
function Component() {
const { data } = useQuery({
queryKey: ['user'],
queryFn: fetchUser,
throwOnError: true, // Throw errors to error boundary
});
return <div>{data.name}</div>;
}
Custom Error Types:
class APIError extends Error {
constructor(
message: string,
public status: number,
public code?: string
) {
super(message);
this.name = 'APIError';
}
}
const { error } = useQuery({
queryKey: ['user'],
queryFn: async () => {
const response = await fetch('/api/user');
if (!response.ok) {
throw new APIError(
'Failed to fetch user',
response.status,
await response.text()
);
}
return response.json();
},
});
if (error instanceof APIError) {
if (error.status === 404) return <NotFound />;
if (error.status === 401) return <Unauthorized />;
}
Retry Logic
Default Retry:
// Retries 3 times with exponential backoff
useQuery({
queryKey: ['data'],
queryFn: fetchData,
retry: 3, // Number of retries
retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
});
Conditional Retry:
useQuery({
queryKey: ['data'],
queryFn: fetchData,
retry: (failureCount, error) => {
// Don't retry on 404
if (error instanceof APIError && error.status === 404) {
return false;
}
// Retry up to 3 times for other errors
return failureCount < 3;
},
});
Mutation Retry:
useMutation({
mutationFn: createUser,
retry: 2, // Retry mutations (use sparingly)
retryDelay: 1000,
});
Network Status Detection
Online/Offline Handling:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
networkMode: 'offlineFirst', // 'online' | 'always' | 'offlineFirst'
refetchOnReconnect: true,
},
},
});
// Custom online/offline indicator
function OnlineStatus() {
const queryClient = useQueryClient();
const isOnline = useOnlineManager().isOnline();
useEffect(() => {
if (isOnline) {
queryClient.refetchQueries();
}
}, [isOnline, queryClient]);
return isOnline ? <OnlineIcon /> : <OfflineIcon />;
}
SSR and Hydration
Next.js App Router
Server Component Data Fetching:
// app/users/page.tsx
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
import { UsersList } from './UsersList';
export default async function UsersPage() {
const queryClient = new QueryClient();
// Prefetch on server
await queryClient.prefetchQuery({
queryKey: ['users'],
queryFn: fetchUsers,
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<UsersList />
</HydrationBoundary>
);
}
Client Component:
// app/users/UsersList.tsx
'use client';
import { useQuery } from '@tanstack/react-query';
export function UsersList() {
// Uses hydrated data from server
const { data } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
});
return (
<ul>
{data?.map(user => <li key={user.id}>{user.name}</li>)}
</ul>
);
}
Next.js Pages Router
getServerSideProps:
import { dehydrate, QueryClient } from '@tanstack/react-query';
export async function getServerSideProps() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: ['users'],
queryFn: fetchUsers,
});
return {
props: {
dehydratedState: dehydrate(queryClient),
},
};
}
function UsersPage() {
const { data } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
});
return <div>{/* ... */}</div>;
}
export default UsersPage;
_app.tsx Setup:
// pages/_app.tsx
import { useState } from 'react';
import { QueryClient, QueryClientProvider, HydrationBoundary } from '@tanstack/react-query';
export default function App({ Component, pageProps }) {
const [queryClient] = useState(() => new QueryClient());
return (
<QueryClientProvider client={queryClient}>
<HydrationBoundary state={pageProps.dehydratedState}>
<Component {...pageProps} />
</HydrationBoundary>
</QueryClientProvider>
);
}
Streaming SSR
Suspense Integration:
import { useSuspenseQuery } from '@tanstack/react-query';
function UserProfile({ userId }: { userId: number }) {
const { data } = useSuspenseQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
// No loading state needed - Suspense handles it
return <div>{data.name}</div>;
}
// In parent component
<Suspense fallback={<Spinner />}>
<UserProfile userId={1} />
</Suspense>
Integration Patterns
tRPC Integration
Setup:
// utils/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@/server/routers/_app';
export const trpc = createTRPCReact<AppRouter>();
Provider:
// app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { useState } from 'react';
import { trpc } from '@/utils/trpc';
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: '/api/trpc',
}),
],
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
);
}
Usage:
function UserProfile() {
// Query
const { data } = trpc.user.getById.useQuery({ id: 1 });
// Mutation
const utils = trpc.useUtils();
const mutation = trpc.user.create.useMutation({
onSuccess: () => {
utils.user.list.invalidate();
},
});
return <div>{data?.name}</div>;
}
REST API with Axios
API Client:
// lib/api-client.ts
import axios from 'axios';
export const apiClient = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor
apiClient.interceptors.request.use(config => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Response interceptor
apiClient.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401) {
// Handle unauthorized
window.location.href = '/login';
}
return Promise.reject(error);
}
);
Query Hooks:
// hooks/useUsers.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
export function useUsers() {
return useQuery({
queryKey: ['users'],
queryFn: async ({ signal }) => {
const { data } = await apiClient.get('/users', { signal });
return data;
},
});
}
export function useUser(id: number) {
return useQuery({
queryKey: ['user', id],
queryFn: async ({ signal }) => {
const { data } = await apiClient.get(`/users/${id}`, { signal });
return data;
},
enabled: !!id,
});
}
export function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (newUser: NewUser) =>
apiClient.post('/users', newUser).then(res => res.data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}
GraphQL Integration
Apollo Client Alternative:
import { useQuery } from '@tanstack/react-query';
import { request, gql } from 'graphql-request';
const endpoint = 'https://api.example.com/graphql';
const GET_USERS = gql`
query GetUsers {
users {
id
name
email
}
}
`;
function useUsers() {
return useQuery({
queryKey: ['users'],
queryFn: async () => request(endpoint, GET_USERS),
});
}
// With variables
const GET_USER = gql`
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
}
}
`;
function useUser(id: string) {
return useQuery({
queryKey: ['user', id],
queryFn: async () => request(endpoint, GET_USER, { id }),
});
}
Zustand for Global State
Combined Pattern:
// store/useAuthStore.ts
import { create } from 'zustand';
interface AuthState {
token: string | null;
setToken: (token: string | null) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>((set) => ({
token: localStorage.getItem('token'),
setToken: (token) => {
if (token) {
localStorage.setItem('token', token);
} else {
localStorage.removeItem('token');
}
set({ token });
},
logout: () => {
localStorage.removeItem('token');
set({ token: null });
},
}));
// hooks/useAuthenticatedQuery.ts
import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '@/store/useAuthStore';
export function useAuthenticatedQuery() {
const token = useAuthStore(state => state.token);
return useQuery({
queryKey: ['profile', token],
queryFn: async () => {
const response = await fetch('/api/profile', {
headers: { Authorization: `Bearer ${token}` },
});
return response.json();
},
enabled: !!token,
});
}
TypeScript Patterns
Typed Queries
Generic Query Hook:
interface User {
id: number;
name: string;
email: string;
}
// Explicit typing
const { data } = useQuery<User, Error>({
queryKey: ['user', id],
queryFn: async () => {
const response = await fetch(`/api/users/${id}`);
return response.json(); // TypeScript infers return type
},
});
// data is User | undefined
// error is Error | null
Type-safe Query Keys:
// Define query keys with types
const userKeys = {
all: ['users'] as const,
lists: () => [...userKeys.all, 'list'] as const,
list: (filters: UserFilters) => [...userKeys.lists(), filters] as const,
details: () => [...userKeys.all, 'detail'] as const,
detail: (id: number) => [...userKeys.details(), id] as const,
};
// Usage with full type safety
const { data } = useQuery({
queryKey: userKeys.detail(userId),
queryFn: () => fetchUser(userId),
});
// Invalidate with autocomplete
queryClient.invalidateQueries({ queryKey: userKeys.lists() });
Custom Hook with Types:
interface User {
id: number;
name: string;
email: string;
}
interface UseUserOptions {
enabled?: boolean;
onSuccess?: (user: User) => void;
}
function useUser(id: number, options?: UseUserOptions) {
return useQuery({
queryKey: ['user', id],
queryFn: async (): Promise<User> => {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error('Failed to fetch user');
return response.json();
},
enabled: options?.enabled,
// Type-safe callbacks
onSuccess: options?.onSuccess,
});
}
// Usage
const { data } = useUser(1, {
enabled: true,
onSuccess: (user) => {
console.log(user.name); // TypeScript knows user is User
},
});
Typed Mutations
interface CreateUserPayload {
name: string;
email: string;
}
interface User {
id: number;
name: string;
email: string;
}
function useCreateUser() {
return useMutation<User, Error, CreateUserPayload>({
mutationFn: async (payload) => {
const response = await fetch('/api/users', {
method: 'POST',
body: JSON.stringify(payload),
});
return response.json();
},
onSuccess: (data) => {
// data is User
console.log('Created user:', data.name);
},
onError: (error) => {
// error is Error
console.error('Failed:', error.message);
},
});
}
// Usage
const mutation = useCreateUser();
mutation.mutate({ name: 'John', email: 'john@example.com' });
Query Client Typing
import { QueryClient } from '@tanstack/react-query';
// Type-safe query client methods
const user = queryClient.getQueryData<User>(['user', 1]);
queryClient.setQueryData<User>(['user', 1], (old) => {
// old is User | undefined
if (!old) return old;
return { ...old, name: 'Updated' };
});
// Type-safe invalidation
queryClient.invalidateQueries<User>({
queryKey: ['users'],
predicate: (query) => {
// query.state.data is User | undefined
return query.state.data?.isActive === true;
},
});
Testing
Setup Testing Environment
Test Utils:
// test/utils.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render } from '@testing-library/react';
import { ReactNode } from 'react';
export function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false, // Don't retry failed queries in tests
gcTime: Infinity,
},
},
logger: {
log: console.log,
warn: console.warn,
error: () => {}, // Silence errors in tests
},
});
}
export function renderWithClient(ui: ReactNode) {
const testQueryClient = createTestQueryClient();
return render(
<QueryClientProvider client={testQueryClient}>
{ui}
</QueryClientProvider>
);
}
Testing Queries
Basic Query Test:
// UserProfile.test.tsx
import { renderWithClient } from '@/test/utils';
import { screen, waitFor } from '@testing-library/react';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { UserProfile } from './UserProfile';
const server = setupServer(
rest.get('/api/users/1', (req, res, ctx) => {
return res(
ctx.json({
id: 1,
name: 'John Doe',
email: 'john@example.com',
})
);
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('displays user profile', async () => {
renderWithClient(<UserProfile userId={1} />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
});
test('handles fetch error', async () => {
server.use(
rest.get('/api/users/1', (req, res, ctx) => {
return res(ctx.status(500));
})
);
renderWithClient(<UserProfile userId={1} />);
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument();
});
});
Testing Mutations
// CreateUserForm.test.tsx
import { renderWithClient } from '@/test/utils';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { CreateUserForm } from './CreateUserForm';
const server = setupServer(
rest.post('/api/users', async (req, res, ctx) => {
const body = await req.json();
return res(
ctx.json({
id: 1,
...body,
})
);
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('creates user successfully', async () => {
const user = userEvent.setup();
renderWithClient(<CreateUserForm />);
await user.type(screen.getByPlaceholderText('Name'), 'John Doe');
await user.type(screen.getByPlaceholderText('Email'), 'john@example.com');
await user.click(screen.getByRole('button', { name: /create/i }));
await waitFor(() => {
expect(screen.getByText(/created successfully/i)).toBeInTheDocument();
});
});
Testing with Mock Data
Hydrate Query Data:
test('renders with initial data', () => {
const testQueryClient = createTestQueryClient();
// Pre-populate cache
testQueryClient.setQueryData(['user', 1], {
id: 1,
name: 'John Doe',
email: 'john@example.com',
});
render(
<QueryClientProvider client={testQueryClient}>
<UserProfile userId={1} />
</QueryClientProvider>
);
// Data immediately available (no loading state)
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
Testing Custom Hooks
// useUser.test.ts
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { useUser } from './useUser';
const server = setupServer(
rest.get('/api/users/:id', (req, res, ctx) => {
return res(ctx.json({ id: 1, name: 'John Doe' }));
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('fetches user data', async () => {
const queryClient = new QueryClient();
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useUser(1), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual({ id: 1, name: 'John Doe' });
});
Performance Optimization
Query Deduplication
Automatic Deduplication:
// Multiple components request same data - only one network request
function Dashboard() {
return (
<div>
<UserStats userId={1} /> {/* Triggers fetch */}
<UserProfile userId={1} /> {/* Uses cache */}
<UserActivity userId={1} /> {/* Uses cache */}
</div>
);
}
Prefetching
Hover Prefetch:
function UserLink({ userId }: { userId: number }) {
const queryClient = useQueryClient();
const prefetchUser = () => {
queryClient.prefetchQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
staleTime: 60000,
});
};
return (
<Link
href={`/users/${userId}`}
onMouseEnter={prefetchUser}
onFocus={prefetchUser}
>
View User
</Link>
);
}
Route Prefetch:
// Next.js App Router
import { QueryClient, HydrationBoundary, dehydrate } from '@tanstack/react-query';
export default async function UserPage({ params }: { params: { id: string } }) {
const queryClient = new QueryClient();
// Prefetch user data
await queryClient.prefetchQuery({
queryKey: ['user', params.id],
queryFn: () => fetchUser(params.id),
});
// Prefetch related data
await queryClient.prefetchQuery({
queryKey: ['user-posts', params.id],
queryFn: () => fetchUserPosts(params.id),
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<UserProfile userId={params.id} />
</HydrationBoundary>
);
}
Select and Transform Data
Memo-ized Selectors:
// Only re-render when selected data changes
function TodoList({ filter }: { filter: 'all' | 'done' | 'pending' }) {
const { data: filteredTodos } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: (todos) => {
// This only runs when todos change
if (filter === 'done') return todos.filter(t => t.done);
if (filter === 'pending') return todos.filter(t => !t.done);
return todos;
},
});
// Component only re-renders when filteredTodos change
return (
<ul>
{filteredTodos?.map(todo => <li key={todo.id}>{todo.title}</li>)}
</ul>
);
}
Expensive Computations:
const { data: sortedUsers } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
select: (users) => {
// Heavy sorting only runs when users change
return users
.slice()
.sort((a, b) => a.name.localeCompare(b.name));
},
});
Structural Sharing
Automatic Structural Sharing:
// TanStack Query automatically does structural sharing
const { data } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
structuralSharing: true, // Default
});
// If refetch returns identical data structure,
// component doesn't re-render even though fetch completed
Custom Structural Sharing:
import { replaceEqualDeep } from '@tanstack/react-query';
const { data } = useQuery({
queryKey: ['data'],
queryFn: fetchData,
structuralSharing: (oldData, newData) => {
// Custom comparison logic
return replaceEqualDeep(oldData, newData);
},
});
Query Cancellation
Abort In-Flight Requests:
const { data, refetch } = useQuery({
queryKey: ['search', searchTerm],
queryFn: async ({ signal }) => {
const response = await fetch(`/api/search?q=${searchTerm}`, {
signal, // Pass abort signal
});
return response.json();
},
});
// When searchTerm changes, previous request is cancelled automatically
Manual Cancellation:
const queryClient = useQueryClient();
// Cancel all queries
queryClient.cancelQueries();
// Cancel specific query
queryClient.cancelQueries({ queryKey: ['todos'] });
Best Practices and Common Patterns
Query Key Factories
Centralized Query Keys:
// lib/query-keys.ts
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: number) => [...queryKeys.users.details(), id] as const,
},
posts: {
all: ['posts'] as const,
lists: () => [...queryKeys.posts.all, 'list'] as const,
list: (filters: PostFilters) => [...queryKeys.posts.lists(), filters] as const,
details: () => [...queryKeys.posts.all, 'detail'] as const,
detail: (id: number) => [...queryKeys.posts.details(), id] as const,
},
};
// Usage
const { data } = useQuery({
queryKey: queryKeys.users.detail(userId),
queryFn: () => fetchUser(userId),
});
// Invalidate all user lists
queryClient.invalidateQueries({ queryKey: queryKeys.users.lists() });
Custom Hook Patterns
Resource Hook Factory:
// lib/create-resource-hooks.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
export function createResourceHooks<T, CreateT = Partial<T>, UpdateT = Partial<T>>(
resourceName: string,
api: {
getAll: () => Promise<T[]>;
getOne: (id: string | number) => Promise<T>;
create: (data: CreateT) => Promise<T>;
update: (id: string | number, data: UpdateT) => Promise<T>;
delete: (id: string | number) => Promise<void>;
}
) {
const keys = {
all: [resourceName] as const,
lists: () => [...keys.all, 'list'] as const,
details: () => [...keys.all, 'detail'] as const,
detail: (id: string | number) => [...keys.details(), id] as const,
};
return {
useList: () =>
useQuery({
queryKey: keys.lists(),
queryFn: api.getAll,
}),
useDetail: (id: string | number) =>
useQuery({
queryKey: keys.detail(id),
queryFn: () => api.getOne(id),
enabled: !!id,
}),
useCreate: () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: keys.lists() });
},
});
},
useUpdate: () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string | number; data: UpdateT }) =>
api.update(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: keys.detail(id) });
queryClient.invalidateQueries({ queryKey: keys.lists() });
},
});
},
useDelete: () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: keys.lists() });
},
});
},
};
}
// Usage
const userHooks = createResourceHooks('users', userApi);
function UsersList() {
const { data: users } = userHooks.useList();
const createUser = userHooks.useCreate();
const deleteUser = userHooks.useDelete();
return (
<div>
{users?.map(user => (
<div key={user.id}>
{user.name}
<button onClick={() => deleteUser.mutate(user.id)}>Delete</button>
</div>
))}
</div>
);
}
Error Handling Patterns
Centralized Error Handler:
// lib/query-client.ts
import { QueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
onError: (error) => {
if (error instanceof APIError) {
toast.error(`Error: ${error.message}`);
}
},
},
mutations: {
onError: (error) => {
toast.error(`Failed to save: ${error.message}`);
},
},
},
});
Migration from SWR
SWR to TanStack Query:
// Before (SWR)
import useSWR from 'swr';
function Profile() {
const { data, error, mutate } = useSWR('/api/user', fetcher);
if (error) return <div>Error</div>;
if (!data) return <div>Loading...</div>;
return <div>{data.name}</div>;
}
// After (TanStack Query)
import { useQuery, useQueryClient } from '@tanstack/react-query';
function Profile() {
const { data, error, isLoading } = useQuery({
queryKey: ['/api/user'],
queryFn: () => fetcher('/api/user'),
});
const queryClient = useQueryClient();
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['/api/user'] });
if (error) return <div>Error</div>;
if (isLoading) return <div>Loading...</div>;
return <div>{data.name}</div>;
}
Comparison:
useSWR(key, fetcher)→useQuery({ queryKey: [key], queryFn: fetcher })mutate()→queryClient.invalidateQueries()!dataloading →isLoadinguseSWRConfig()→useQueryClient()
DevTools
Setup DevTools:
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
Production Build:
// DevTools are automatically excluded in production builds
// No need to conditionally render
DevTools Features:
- View all queries and their states
- Inspect query data and errors
- Manually trigger refetch
- Invalidate queries
- View query timelines
- Monitor cache size
- Debug network waterfalls
Common Pitfalls
❌ Don't Create QueryClient Inside Component:
// WRONG - Creates new client on every render
function App() {
const queryClient = new QueryClient(); // ❌
return <QueryClientProvider client={queryClient}>...</QueryClientProvider>;
}
// CORRECT - Stable client instance
function App() {
const [queryClient] = useState(() => new QueryClient()); // ✅
return <QueryClientProvider client={queryClient}>...</QueryClientProvider>;
}
❌ Don't Use Query Data in Render Without Checking:
// WRONG - data might be undefined
function UserProfile() {
const { data } = useQuery({ queryKey: ['user'], queryFn: fetchUser });
return <div>{data.name}</div>; // ❌ Crashes if data is undefined
}
// CORRECT - Handle loading state
function UserProfile() {
const { data, isLoading } = useQuery({ queryKey: ['user'], queryFn: fetchUser });
if (isLoading) return <Spinner />; // ✅
return <div>{data.name}</div>;
}
❌ Don't Forget Query Keys Are Dependencies:
// WRONG - Missing dependency in query key
function UserPosts({ userId, filter }: Props) {
const { data } = useQuery({
queryKey: ['posts'], // ❌ Missing userId and filter
queryFn: () => fetchUserPosts(userId, filter),
});
}
// CORRECT - All dependencies in key
function UserPosts({ userId, filter }: Props) {
const { data } = useQuery({
queryKey: ['posts', userId, filter], // ✅
queryFn: () => fetchUserPosts(userId, filter),
});
}
❌ Don't Mutate Query Data Directly:
// WRONG - Mutating cached data
const { data } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos });
data.push(newTodo); // ❌ Mutates cache directly
// CORRECT - Use setQueryData
queryClient.setQueryData(['todos'], (old = []) => [...old, newTodo]); // ✅
Summary
TanStack Query is the industry-standard solution for server-state management in React applications. Use it for API data fetching, caching, synchronization, and real-time updates. It eliminates manual state management boilerplate and provides powerful features like automatic background refetching, optimistic updates, pagination, and intelligent cache management.
Key Takeaways:
- Use
useQueryfor fetching data with automatic caching - Use
useMutationfor create/update/delete operations - Query keys are the foundation of cache management
- Invalidate queries after mutations to keep UI in sync
- Leverage optimistic updates for instant UI feedback
- Use
useInfiniteQueryfor pagination and infinite scroll - Combine with Zustand for client-state management
- Integrate seamlessly with tRPC, REST, and GraphQL
- Type everything with TypeScript for full type safety
- Test with MSW for realistic API mocking
Progressive Loading Pattern:
- Entry Point: Quick start and basic setup
- Intermediate: Queries, mutations, and cache management
- Advanced: Optimistic updates, SSR, integrations, and performance
For additional resources, visit the official documentation.
Related Skills
When using Tanstack Query, these skills enhance your workflow:
- react: React hooks and patterns for integrating TanStack Query
- nextjs: TanStack Query with Next.js App Router and Server Components
- zustand: Complementary client-state management (use together for hybrid state)
- test-driven-development: Testing queries, mutations, and cache behavior
[Full documentation available in these skills if deployed in your bundle]
Recommended Agent Skills
Expand your agent's capabilities with these related and highly-rated skills.
bad-example-skill
ANTI-PATTERN - Example showing violations of self-containment (DO NOT COPY)
example-framework-skill
Example of a properly self-contained skill following all best practices
systematic-debugging
Systematic debugging methodology emphasizing root cause analysis over quick fixes
verification-before-completion
Run verification commands and confirm output before claiming success
Root Cause Tracing
Systematically trace bugs backward through call stack to find original trigger
Brainstorming Ideas Into Designs
Interactive idea refinement using Socratic method to develop fully-formed designs
Didn't find tool you were looking for?