TanStack Query (React Query) for asynchronous server-state management with automatic caching, background refetching, optimistic updates, and pagination in React applications.
tanstack-query follows the SKILL.md standard. Use the install command to add it to your agent stack.
---
name: tanstack-query
description: TanStack Query (React Query) for asynchronous server-state management with automatic caching, background refetching, optimistic updates, and pagination in React applications.
progressive_disclosure:
entry_point:
- summary
- when_to_use
- quick_start
intermediate:
- core_concepts
- queries
- mutations
- cache_management
advanced:
- optimistic_updates
- pagination
- ssr_hydration
- integration_patterns
- performance
---
# 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
```bash
npm install @tanstack/react-query
# DevTools (optional but recommended)
npm install @tanstack/react-query-devtools
```
### Basic Setup
```tsx
// 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
```tsx
// 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
```tsx
// 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:**
```tsx
// 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:**
```tsx
// 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:**
```tsx
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:**
```tsx
// 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:**
```tsx
// 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:**
```tsx
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:**
```tsx
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:**
```tsx
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):**
```tsx
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:**
```tsx
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:**
```tsx
// 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:**
```tsx
const { data } = useQuery({
queryKey: ['premium-features', userId],
queryFn: fetchPremiumFeatures,
enabled: user?.isPremium === true, // Only fetch for premium users
});
```
### Parallel Queries
**Manual Parallel:**
```tsx
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):**
```tsx
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):**
```tsx
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):**
```tsx
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 UI
- `initialData`: Persisted to cache as real data
---
## Mutations
### useMutation Hook
**Basic Mutation:**
```tsx
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:**
```tsx
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:**
```tsx
const mutation = useMutation({
mutationFn: createTodo,
onSuccess: () => {
// Refetch all 'todos' queries
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
```
**Multiple Invalidations:**
```tsx
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:**
```tsx
// 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):**
```tsx
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:**
```tsx
// 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
```tsx
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
```tsx
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:**
```tsx
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:**
```tsx
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:**
```tsx
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:**
```tsx
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:**
```tsx
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):**
```tsx
const todos = queryClient.getQueryData<Todo[]>(['todos']);
const user = queryClient.getQueryData<User>(['user', userId]);
```
**setQueryData (Write Cache):**
```tsx
queryClient.setQueryData(['user', 1], newUser);
// Updater function
queryClient.setQueryData<Todo[]>(['todos'], (old = []) => [...old, newTodo]);
```
**invalidateQueries (Mark Stale + Refetch):**
```tsx
// 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):**
```tsx
// 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):**
```tsx
// 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):**
```tsx
// Reset all queries
queryClient.resetQueries();
// Reset specific
queryClient.resetQueries({ queryKey: ['todos'] });
```
### Cache Configuration
**Global Defaults:**
```tsx
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:**
```tsx
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:**
```tsx
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:**
```tsx
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:**
```tsx
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:**
```tsx
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:**
```tsx
// 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:**
```tsx
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:**
```tsx
useMutation({
mutationFn: createUser,
retry: 2, // Retry mutations (use sparingly)
retryDelay: 1000,
});
```
### Network Status Detection
**Online/Offline Handling:**
```tsx
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:**
```tsx
// 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:**
```tsx
// 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:**
```tsx
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:**
```tsx
// 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:**
```tsx
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:**
```tsx
// utils/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@/server/routers/_app';
export const trpc = createTRPCReact<AppRouter>();
```
**Provider:**
```tsx
// 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:**
```tsx
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:**
```tsx
// 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:**
```tsx
// 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:**
```tsx
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:**
```tsx
// 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:**
```tsx
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:**
```tsx
// 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:**
```tsx
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
```tsx
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
```tsx
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:**
```tsx
// 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:**
```tsx
// 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
```tsx
// 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:**
```tsx
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
```tsx
// 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:**
```tsx
// 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:**
```tsx
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:**
```tsx
// 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:**
```tsx
// 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:**
```tsx
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:**
```tsx
// 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:**
```tsx
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:**
```tsx
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:**
```tsx
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:**
```tsx
// 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:**
```tsx
// 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:**
```tsx
// 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:**
```tsx
// 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()`
- `!data` loading → `isLoading`
- `useSWRConfig()` → `useQueryClient()`
---
## DevTools
**Setup DevTools:**
```tsx
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
```
**Production Build:**
```tsx
// 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:**
```tsx
// 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:**
```tsx
// 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:**
```tsx
// 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:**
```tsx
// 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 `useQuery` for fetching data with automatic caching
- Use `useMutation` for 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 `useInfiniteQuery` for 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](https://tanstack.com/query/latest).
## 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]