Initial commit
This commit is contained in:
915
skills/tanstack-query/SKILL.md
Normal file
915
skills/tanstack-query/SKILL.md
Normal file
@@ -0,0 +1,915 @@
|
||||
---
|
||||
name: tanstack-query
|
||||
description: Comprehensive TanStack Query v5 patterns for async state management. Covers breaking changes, query key factories, data transformation, mutations, optimistic updates, authentication, testing with MSW, and anti-patterns. Use for all server state management, data fetching, and cache invalidation tasks.
|
||||
---
|
||||
|
||||
# TanStack Query v5 - Complete Guide
|
||||
|
||||
|
||||
**TanStack Query v5** (October 2023) is the async state manager for this project. It requires React 18+, features first-class Suspense support, improved TypeScript inference, and a 20% smaller bundle. This section covers production-ready patterns based on official documentation and community best practices.
|
||||
|
||||
### Breaking Changes in v5
|
||||
|
||||
**Key updates you need to know:**
|
||||
|
||||
1. **Single Object Signature**: All hooks now accept one configuration object:
|
||||
```typescript
|
||||
// ✅ v5 - single object
|
||||
useQuery({ queryKey, queryFn, ...options })
|
||||
|
||||
// ❌ v4 - multiple overloads (deprecated)
|
||||
useQuery(queryKey, queryFn, options)
|
||||
```
|
||||
|
||||
2. **Renamed Options**:
|
||||
- `cacheTime` → `gcTime` (garbage collection time)
|
||||
- `keepPreviousData` → `placeholderData: keepPreviousData`
|
||||
- `isLoading` now means `isPending && isFetching`
|
||||
|
||||
3. **Callbacks Removed from useQuery**:
|
||||
- `onSuccess`, `onError`, `onSettled` removed from `useQuery`
|
||||
- Use global QueryCache callbacks instead
|
||||
- Prevents duplicate executions
|
||||
|
||||
4. **Infinite Queries Require initialPageParam**:
|
||||
- No default value provided
|
||||
- Must explicitly set `initialPageParam` (e.g., `0` or `null`)
|
||||
|
||||
5. **First-Class Suspense**:
|
||||
- New dedicated hooks: `useSuspenseQuery`, `useSuspenseInfiniteQuery`
|
||||
- No experimental flag needed
|
||||
- Data is never undefined at type level
|
||||
|
||||
**Migration**: Use the official codemod for automatic migration: `npx @tanstack/query-codemods v5/replace-import-specifier`
|
||||
|
||||
### Smart Defaults
|
||||
|
||||
Query v5 ships with production-ready defaults:
|
||||
|
||||
```typescript
|
||||
{
|
||||
staleTime: 0, // Data instantly stale (refetch on mount)
|
||||
gcTime: 5 * 60_000, // Keep unused cache for 5 minutes
|
||||
retry: 3, // 3 retries with exponential backoff
|
||||
refetchOnWindowFocus: true,// Refetch when user returns to tab
|
||||
refetchOnReconnect: true, // Refetch when network reconnects
|
||||
}
|
||||
```
|
||||
|
||||
**Philosophy**: React Query is an **async state manager, not a data fetcher**. You provide the Promise; Query manages caching, background updates, and synchronization.
|
||||
|
||||
### Client Setup
|
||||
|
||||
```typescript
|
||||
// src/app/providers.tsx
|
||||
import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query'
|
||||
import { toast } from './toast' // Your notification system
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 0, // Adjust per-query
|
||||
gcTime: 5 * 60_000, // 5 minutes (v5: formerly cacheTime)
|
||||
retry: (failureCount, error) => {
|
||||
// Don't retry on 401 (authentication errors)
|
||||
if (error?.response?.status === 401) return false
|
||||
return failureCount < 3
|
||||
},
|
||||
},
|
||||
},
|
||||
queryCache: new QueryCache({
|
||||
onError: (error, query) => {
|
||||
// Only show toast for background errors (when data exists)
|
||||
if (query.state.data !== undefined) {
|
||||
toast.error(`Something went wrong: ${error.message}`)
|
||||
}
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
export function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**DevTools Setup** (auto-excluded in production):
|
||||
|
||||
```typescript
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
```
|
||||
|
||||
### Architecture: Feature-Based Colocation
|
||||
|
||||
**Recommended pattern**: Group queries with related features, not by file type.
|
||||
|
||||
```
|
||||
src/features/
|
||||
├── Todos/
|
||||
│ ├── index.tsx # Feature entry point
|
||||
│ ├── queries.ts # All React Query logic (keys, functions, hooks)
|
||||
│ ├── types.ts # TypeScript types
|
||||
│ └── components/ # Feature-specific components
|
||||
```
|
||||
|
||||
**Export only custom hooks** from query files. Keep query functions and keys private:
|
||||
|
||||
```typescript
|
||||
// features/todos/queries.ts
|
||||
|
||||
// 1. Query Key Factory (hierarchical structure)
|
||||
const todoKeys = {
|
||||
all: ['todos'] as const,
|
||||
lists: () => [...todoKeys.all, 'list'] as const,
|
||||
list: (filters: string) => [...todoKeys.lists(), { filters }] as const,
|
||||
details: () => [...todoKeys.all, 'detail'] as const,
|
||||
detail: (id: number) => [...todoKeys.details(), id] as const,
|
||||
}
|
||||
|
||||
// 2. Query Function (private)
|
||||
const fetchTodos = async (filters: string): Promise<Todo[]> => {
|
||||
const response = await axios.get('/api/todos', { params: { filters } })
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 3. Custom Hook (public API)
|
||||
export const useTodosQuery = (filters: string) => {
|
||||
return useQuery({
|
||||
queryKey: todoKeys.list(filters),
|
||||
queryFn: () => fetchTodos(filters),
|
||||
staleTime: 30_000, // Fresh for 30 seconds
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- Prevents key/function mismatches
|
||||
- Clean public API
|
||||
- Encapsulation and maintainability
|
||||
- Easy to locate all query logic for a feature
|
||||
|
||||
### Query Key Factories (Essential)
|
||||
|
||||
**Structure keys hierarchically** from generic to specific:
|
||||
|
||||
```typescript
|
||||
// ✅ Correct hierarchy
|
||||
['todos'] // Invalidates everything
|
||||
['todos', 'list'] // Invalidates all lists
|
||||
['todos', 'list', { filters }] // Invalidates specific list
|
||||
['todos', 'detail', 1] // Invalidates specific detail
|
||||
|
||||
// ❌ Wrong - flat structure
|
||||
['todos-list-active'] // Can't partially invalidate
|
||||
```
|
||||
|
||||
**Critical rule**: Query keys must include **ALL variables used in queryFn**. Treat query keys like dependency arrays:
|
||||
|
||||
```typescript
|
||||
// ✅ Correct - includes all variables
|
||||
const { data } = useQuery({
|
||||
queryKey: ['todos', filters, sortBy],
|
||||
queryFn: () => fetchTodos(filters, sortBy),
|
||||
})
|
||||
|
||||
// ❌ Wrong - missing variables
|
||||
const { data } = useQuery({
|
||||
queryKey: ['todos'],
|
||||
queryFn: () => fetchTodos(filters, sortBy), // filters/sortBy not in key!
|
||||
})
|
||||
```
|
||||
|
||||
**Type consistency matters**: `['todos', '1']` and `['todos', 1]` are **different keys**. Be consistent with types.
|
||||
|
||||
### Query Options API (Type Safety)
|
||||
|
||||
**The modern pattern** for maximum type safety across your codebase:
|
||||
|
||||
```typescript
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
|
||||
function todoOptions(id: number) {
|
||||
return queryOptions({
|
||||
queryKey: ['todos', id],
|
||||
queryFn: () => fetchTodo(id),
|
||||
staleTime: 5000,
|
||||
})
|
||||
}
|
||||
|
||||
// ✅ Use everywhere with full type safety
|
||||
useQuery(todoOptions(1))
|
||||
queryClient.prefetchQuery(todoOptions(5))
|
||||
queryClient.setQueryData(todoOptions(42).queryKey, newTodo)
|
||||
queryClient.getQueryData(todoOptions(42).queryKey) // Fully typed!
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- Single source of truth for query configuration
|
||||
- Full TypeScript inference for imperatively accessed data
|
||||
- Reusable across hooks and imperative methods
|
||||
- Prevents key/function mismatches
|
||||
|
||||
### Data Transformation Strategies
|
||||
|
||||
Choose the right approach based on your use case:
|
||||
|
||||
**1. Transform in queryFn** - Simple cases where cache should store transformed data:
|
||||
|
||||
```typescript
|
||||
const fetchTodos = async (): Promise<Todo[]> => {
|
||||
const response = await axios.get('/api/todos')
|
||||
return response.data.map(todo => ({
|
||||
...todo,
|
||||
name: todo.name.toUpperCase()
|
||||
}))
|
||||
}
|
||||
```
|
||||
|
||||
**2. Transform with `select` option (RECOMMENDED)** - Enables partial subscriptions:
|
||||
|
||||
```typescript
|
||||
// Only re-renders when filtered data changes
|
||||
export const useTodosQuery = (filters: string) =>
|
||||
useQuery({
|
||||
queryKey: ['todos'],
|
||||
queryFn: fetchTodos,
|
||||
select: (data) => data.filter(todo => todo.status === filters),
|
||||
})
|
||||
|
||||
// Only re-renders when count changes
|
||||
export const useTodosCount = () =>
|
||||
useQuery({
|
||||
queryKey: ['todos'],
|
||||
queryFn: fetchTodos,
|
||||
select: (data) => data.length,
|
||||
})
|
||||
```
|
||||
|
||||
**⚠️ Memoize select functions** to prevent running on every render:
|
||||
|
||||
```typescript
|
||||
// ✅ Stable reference
|
||||
const transformTodos = (data: Todo[]) => expensiveTransform(data)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['todos'],
|
||||
queryFn: fetchTodos,
|
||||
select: transformTodos, // Stable function reference
|
||||
})
|
||||
|
||||
// ❌ Runs on every render
|
||||
const query = useQuery({
|
||||
queryKey: ['todos'],
|
||||
queryFn: fetchTodos,
|
||||
select: (data) => expensiveTransform(data), // New function every render
|
||||
})
|
||||
```
|
||||
|
||||
### TypeScript Best Practices
|
||||
|
||||
**Let TypeScript infer types** from queryFn rather than specifying generics:
|
||||
|
||||
```typescript
|
||||
// ✅ Recommended - inference
|
||||
const { data } = useQuery({
|
||||
queryKey: ['todos'],
|
||||
queryFn: fetchTodos, // Returns Promise<Todo[]>
|
||||
})
|
||||
// data is Todo[] | undefined
|
||||
|
||||
// ❌ Unnecessary - explicit generics
|
||||
const { data } = useQuery<Todo[]>({
|
||||
queryKey: ['todos'],
|
||||
queryFn: fetchTodos,
|
||||
})
|
||||
```
|
||||
|
||||
**Discriminated unions** automatically narrow types:
|
||||
|
||||
```typescript
|
||||
const { data, isSuccess, isError, error } = useQuery({
|
||||
queryKey: ['todos'],
|
||||
queryFn: fetchTodos,
|
||||
})
|
||||
|
||||
if (isSuccess) {
|
||||
// data is Todo[] (never undefined)
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
// error is defined
|
||||
}
|
||||
```
|
||||
|
||||
Use `queryOptions` helper for maximum type safety across imperative methods.
|
||||
|
||||
### Custom Hooks Pattern
|
||||
|
||||
**Always create custom hooks** even for single queries:
|
||||
|
||||
```typescript
|
||||
// ✅ Recommended - custom hook with encapsulation
|
||||
export function usePost(
|
||||
id: number,
|
||||
options?: Omit<UseQueryOptions<Post>, 'queryKey' | 'queryFn'>
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ['posts', id],
|
||||
queryFn: () => getPost(id),
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
// Usage: allows callers to override any option except key/fn
|
||||
const { data } = usePost(42, { staleTime: 10_000 })
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- Centralizes query logic
|
||||
- Easy to update all usages
|
||||
- Consistent configuration
|
||||
- Better testing
|
||||
|
||||
### Error Handling (Multi-Layer Strategy)
|
||||
|
||||
**Layer 1: Component-Level** - Specific user feedback:
|
||||
|
||||
```typescript
|
||||
function TodoList() {
|
||||
const { data, error, isError, isLoading } = useQuery({
|
||||
queryKey: ['todos'],
|
||||
queryFn: fetchTodos,
|
||||
})
|
||||
|
||||
if (isLoading) return <Spinner />
|
||||
if (isError) return <ErrorAlert>{error.message}</ErrorAlert>
|
||||
|
||||
return <ul>{data.map(todo => <TodoItem key={todo.id} {...todo} />)}</ul>
|
||||
}
|
||||
```
|
||||
|
||||
**Layer 2: Global Error Handling** - Background errors via QueryCache:
|
||||
|
||||
```typescript
|
||||
// Already configured in client setup above
|
||||
queryCache: new QueryCache({
|
||||
onError: (error, query) => {
|
||||
if (query.state.data !== undefined) {
|
||||
toast.error(`Background error: ${error.message}`)
|
||||
}
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
**Layer 3: Error Boundaries** - Catch render errors:
|
||||
|
||||
```typescript
|
||||
import { QueryErrorResetBoundary } from '@tanstack/react-query'
|
||||
import { ErrorBoundary } from 'react-error-boundary'
|
||||
|
||||
<QueryErrorResetBoundary>
|
||||
{({ reset }) => (
|
||||
<ErrorBoundary
|
||||
onReset={reset}
|
||||
fallbackRender={({ error, resetErrorBoundary }) => (
|
||||
<div>
|
||||
<p>Error: {error.message}</p>
|
||||
<button onClick={resetErrorBoundary}>Try again</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<TodoList />
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</QueryErrorResetBoundary>
|
||||
```
|
||||
|
||||
### Suspense Integration
|
||||
|
||||
**First-class Suspense support** in v5 with dedicated hooks:
|
||||
|
||||
```typescript
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
|
||||
function TodoList() {
|
||||
// data is NEVER undefined (type-safe)
|
||||
const { data } = useSuspenseQuery({
|
||||
queryKey: ['todos'],
|
||||
queryFn: fetchTodos,
|
||||
})
|
||||
|
||||
return <ul>{data.map(todo => <TodoItem key={todo.id} {...todo} />)}</ul>
|
||||
}
|
||||
|
||||
// Wrap with Suspense boundary
|
||||
function App() {
|
||||
return (
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<TodoList />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- Eliminates loading state management
|
||||
- Data always defined (TypeScript enforced)
|
||||
- Cleaner component code
|
||||
- Works with React.lazy for code-splitting
|
||||
|
||||
### Mutations with Optimistic Updates
|
||||
|
||||
**Basic mutation** with cache invalidation:
|
||||
|
||||
```typescript
|
||||
export function useCreateTodo() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (newTodo: CreateTodoDTO) =>
|
||||
api.post('/todos', newTodo).then(res => res.data),
|
||||
onSuccess: (data) => {
|
||||
// Set detail query immediately
|
||||
queryClient.setQueryData(['todos', data.id], data)
|
||||
// Invalidate list queries
|
||||
queryClient.invalidateQueries({ queryKey: ['todos', 'list'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Simple optimistic updates** using `variables`:
|
||||
|
||||
```typescript
|
||||
const addTodoMutation = useMutation({
|
||||
mutationFn: (newTodo: string) => axios.post('/api/todos', { text: newTodo }),
|
||||
onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
|
||||
})
|
||||
|
||||
const { isPending, variables, mutate } = addTodoMutation
|
||||
|
||||
return (
|
||||
<ul>
|
||||
{todoQuery.data?.map(todo => <li key={todo.id}>{todo.text}</li>)}
|
||||
{isPending && <li style={{ opacity: 0.5 }}>{variables}</li>}
|
||||
</ul>
|
||||
)
|
||||
```
|
||||
|
||||
**Advanced optimistic updates** with rollback:
|
||||
|
||||
```typescript
|
||||
useMutation({
|
||||
mutationFn: updateTodo,
|
||||
onMutate: async (newTodo) => {
|
||||
// Cancel outgoing queries (prevent race conditions)
|
||||
await queryClient.cancelQueries({ queryKey: ['todos'] })
|
||||
|
||||
// Snapshot current data
|
||||
const previousTodos = queryClient.getQueryData(['todos'])
|
||||
|
||||
// Optimistically update cache
|
||||
queryClient.setQueryData(['todos'], (old: Todo[]) =>
|
||||
old?.map(todo => todo.id === newTodo.id ? newTodo : todo)
|
||||
)
|
||||
|
||||
// Return context for rollback
|
||||
return { previousTodos }
|
||||
},
|
||||
onError: (err, newTodo, context) => {
|
||||
// Rollback on error
|
||||
queryClient.setQueryData(['todos'], context?.previousTodos)
|
||||
toast.error('Update failed. Changes reverted.')
|
||||
},
|
||||
onSettled: () => {
|
||||
// Always refetch to ensure consistency
|
||||
queryClient.invalidateQueries({ queryKey: ['todos'] })
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
**Key principles**:
|
||||
- Cancel ongoing queries in `onMutate` to prevent race conditions
|
||||
- Snapshot previous data before updating
|
||||
- Restore snapshot on error
|
||||
- Always invalidate in `onSettled` for eventual consistency
|
||||
- **Never mutate cached data directly** - always use immutable updates
|
||||
|
||||
### Authentication Integration
|
||||
|
||||
**Handle token refresh at HTTP client level** (not React Query):
|
||||
|
||||
```typescript
|
||||
// src/lib/api-client.ts
|
||||
import axios from 'axios'
|
||||
import createAuthRefreshInterceptor from 'axios-auth-refresh'
|
||||
|
||||
export const apiClient = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL,
|
||||
})
|
||||
|
||||
// Add token to requests
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
const token = getAccessToken()
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
return config
|
||||
})
|
||||
|
||||
// Refresh token on 401
|
||||
const refreshAuth = async (failedRequest: any) => {
|
||||
try {
|
||||
const newToken = await fetchNewToken()
|
||||
failedRequest.response.config.headers.Authorization = `Bearer ${newToken}`
|
||||
setAccessToken(newToken)
|
||||
return Promise.resolve()
|
||||
} catch {
|
||||
removeAccessToken()
|
||||
window.location.href = '/login'
|
||||
return Promise.reject()
|
||||
}
|
||||
}
|
||||
|
||||
createAuthRefreshInterceptor(apiClient, refreshAuth, {
|
||||
statusCodes: [401],
|
||||
pauseInstanceWhileRefreshing: true,
|
||||
})
|
||||
```
|
||||
|
||||
**Protected queries** use the `enabled` option:
|
||||
|
||||
```typescript
|
||||
const useTodos = () => {
|
||||
const { user } = useUser() // Get current user from auth context
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['todos', user?.id],
|
||||
queryFn: () => fetchTodos(user.id),
|
||||
enabled: !!user, // Only execute when user exists
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**On logout**: Clear the entire cache with `queryClient.clear()` (not `invalidateQueries()` which triggers refetches):
|
||||
|
||||
```typescript
|
||||
const logout = () => {
|
||||
removeAccessToken()
|
||||
queryClient.clear() // Clear all cached data
|
||||
navigate('/login')
|
||||
}
|
||||
```
|
||||
|
||||
### Advanced Patterns
|
||||
|
||||
**Prefetching** - Eliminate loading states:
|
||||
|
||||
```typescript
|
||||
// Hover prefetching
|
||||
function ShowDetailsButton() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const prefetch = () => {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ['details'],
|
||||
queryFn: getDetailsData,
|
||||
staleTime: 60_000, // Consider fresh for 1 minute
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<button onMouseEnter={prefetch} onClick={showDetails}>
|
||||
Show Details
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// Route-level prefetching (see Router × Query Integration section)
|
||||
```
|
||||
|
||||
**Infinite Queries** - Infinite scrolling/pagination:
|
||||
|
||||
```typescript
|
||||
function Projects() {
|
||||
const {
|
||||
data,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
isLoading,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: ['projects'],
|
||||
queryFn: ({ pageParam }) => fetchProjects(pageParam),
|
||||
initialPageParam: 0, // Required in v5
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
})
|
||||
|
||||
if (isLoading) return <Spinner />
|
||||
|
||||
return (
|
||||
<>
|
||||
{data.pages.map((page, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{page.data.map(project => (
|
||||
<ProjectCard key={project.id} {...project} />
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={!hasNextPage || isFetchingNextPage}
|
||||
>
|
||||
{isFetchingNextPage ? 'Loading...' : 'Load More'}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Offset-Based Pagination** with `placeholderData`:
|
||||
|
||||
```typescript
|
||||
import { keepPreviousData } from '@tanstack/react-query'
|
||||
|
||||
function Posts() {
|
||||
const [page, setPage] = useState(0)
|
||||
|
||||
const { data, isPending, isPlaceholderData } = useQuery({
|
||||
queryKey: ['posts', page],
|
||||
queryFn: () => fetchPosts(page),
|
||||
placeholderData: keepPreviousData, // Show previous data while fetching
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
{data.posts.map(post => <PostCard key={post.id} {...post} />)}
|
||||
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
disabled={isPlaceholderData || !data.hasMore}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Dependent Queries** - Sequential data fetching:
|
||||
|
||||
```typescript
|
||||
function UserProjects({ email }: { email: string }) {
|
||||
// First query
|
||||
const { data: user } = useQuery({
|
||||
queryKey: ['user', email],
|
||||
queryFn: () => getUserByEmail(email),
|
||||
})
|
||||
|
||||
// Second query waits for first
|
||||
const { data: projects } = useQuery({
|
||||
queryKey: ['projects', user?.id],
|
||||
queryFn: () => getProjectsByUser(user.id),
|
||||
enabled: !!user?.id, // Only runs when user.id exists
|
||||
})
|
||||
|
||||
return <div>{/* render projects */}</div>
|
||||
}
|
||||
```
|
||||
|
||||
### Performance Optimization
|
||||
|
||||
**staleTime is your primary control** - adjust this, not `gcTime`:
|
||||
|
||||
```typescript
|
||||
// Real-time data (default)
|
||||
staleTime: 0 // Always considered stale, refetch on mount
|
||||
|
||||
// User profiles (changes infrequently)
|
||||
staleTime: 1000 * 60 * 2 // Fresh for 2 minutes
|
||||
|
||||
// Static reference data
|
||||
staleTime: 1000 * 60 * 10 // Fresh for 10 minutes
|
||||
```
|
||||
|
||||
**Query deduplication** happens automatically - multiple components mounting with identical query keys result in a single network request, but all components receive data.
|
||||
|
||||
**Prevent request waterfalls**:
|
||||
|
||||
```typescript
|
||||
// ❌ Waterfall - each query waits for previous
|
||||
function Dashboard() {
|
||||
const { data: user } = useQuery(userQuery)
|
||||
const { data: posts } = useQuery(postsQuery(user?.id))
|
||||
const { data: stats } = useQuery(statsQuery(user?.id))
|
||||
}
|
||||
|
||||
// ✅ Parallel - all queries start simultaneously
|
||||
function Dashboard() {
|
||||
const { data: user } = useQuery(userQuery)
|
||||
const { data: posts } = useQuery({
|
||||
...postsQuery(user?.id),
|
||||
enabled: !!user?.id,
|
||||
})
|
||||
const { data: stats } = useQuery({
|
||||
...statsQuery(user?.id),
|
||||
enabled: !!user?.id,
|
||||
})
|
||||
}
|
||||
|
||||
// ✅ Best - prefetch in route loader (see Router × Query Integration)
|
||||
```
|
||||
|
||||
**Never copy server state to local state** - this opts out of background updates:
|
||||
|
||||
```typescript
|
||||
// ❌ Wrong - copies to state, loses reactivity
|
||||
const { data } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
|
||||
const [todos, setTodos] = useState(data)
|
||||
|
||||
// ✅ Correct - use query data directly
|
||||
const { data: todos } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
|
||||
```
|
||||
|
||||
### Testing with Mock Service Worker (MSW)
|
||||
|
||||
**MSW is the recommended approach** - mock the network layer:
|
||||
|
||||
```typescript
|
||||
// src/test/mocks/handlers.ts
|
||||
import { http, HttpResponse } from 'msw'
|
||||
|
||||
export const handlers = [
|
||||
http.get('/api/todos', () => {
|
||||
return HttpResponse.json([
|
||||
{ id: 1, text: 'Test todo', completed: false },
|
||||
])
|
||||
}),
|
||||
|
||||
http.post('/api/todos', async ({ request }) => {
|
||||
const newTodo = await request.json()
|
||||
return HttpResponse.json({ id: 2, ...newTodo })
|
||||
}),
|
||||
]
|
||||
|
||||
// src/test/setup.ts
|
||||
import { setupServer } from 'msw/node'
|
||||
import { handlers } from './mocks/handlers'
|
||||
|
||||
export const server = setupServer(...handlers)
|
||||
|
||||
beforeAll(() => server.listen())
|
||||
afterEach(() => server.resetHandlers())
|
||||
afterAll(() => server.close())
|
||||
```
|
||||
|
||||
**Create test wrappers** with proper QueryClient:
|
||||
|
||||
```typescript
|
||||
// src/test/utils.tsx
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render } from '@testing-library/react'
|
||||
|
||||
export function createTestQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false, // Prevent retries in tests
|
||||
gcTime: Infinity,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function renderWithClient(ui: React.ReactElement) {
|
||||
const testQueryClient = createTestQueryClient()
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={testQueryClient}>
|
||||
{ui}
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Test queries**:
|
||||
|
||||
```typescript
|
||||
import { renderWithClient } from '@/test/utils'
|
||||
import { screen } from '@testing-library/react'
|
||||
|
||||
test('displays todos', async () => {
|
||||
renderWithClient(<TodoList />)
|
||||
|
||||
// Wait for data to load
|
||||
expect(await screen.findByText('Test todo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('shows error state', async () => {
|
||||
// Override handler for this test
|
||||
server.use(
|
||||
http.get('/api/todos', () => {
|
||||
return HttpResponse.json(
|
||||
{ message: 'Failed to fetch' },
|
||||
{ status: 500 }
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
renderWithClient(<TodoList />)
|
||||
|
||||
expect(await screen.findByText(/failed/i)).toBeInTheDocument()
|
||||
})
|
||||
```
|
||||
|
||||
**Critical testing principles**:
|
||||
- Create new QueryClient per test for isolation
|
||||
- Set `retry: false` to prevent timeouts
|
||||
- Use async queries (`findBy*`) for data that loads
|
||||
- Silence console.error for expected errors
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
|
||||
**❌ Don't store query data in Redux/Context**:
|
||||
- Creates dual sources of truth
|
||||
- Loses automatic cache invalidation
|
||||
- Triggers unnecessary renders
|
||||
|
||||
**❌ Don't call refetch() with different parameters**:
|
||||
```typescript
|
||||
// ❌ Wrong - breaks declarative pattern
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: ['todos'],
|
||||
queryFn: () => fetchTodos(filters),
|
||||
})
|
||||
// Later: refetch with different filters??? Won't work!
|
||||
|
||||
// ✅ Correct - include params in key
|
||||
const [filters, setFilters] = useState('all')
|
||||
const { data } = useQuery({
|
||||
queryKey: ['todos', filters],
|
||||
queryFn: () => fetchTodos(filters),
|
||||
})
|
||||
// Changing filters automatically refetches
|
||||
```
|
||||
|
||||
**❌ Don't use queries for local state**:
|
||||
- Query Cache expects refetchable data
|
||||
- Use useState/useReducer for client-only state
|
||||
|
||||
**❌ Don't create QueryClient inside components**:
|
||||
```typescript
|
||||
// ❌ Wrong - new cache every render
|
||||
function App() {
|
||||
const client = new QueryClient()
|
||||
return <QueryClientProvider client={client}>...</QueryClientProvider>
|
||||
}
|
||||
|
||||
// ✅ Correct - stable instance
|
||||
const queryClient = new QueryClient()
|
||||
function App() {
|
||||
return <QueryClientProvider client={queryClient}>...</QueryClientProvider>
|
||||
}
|
||||
```
|
||||
|
||||
**❌ Don't ignore loading and error states** - always handle both
|
||||
|
||||
**❌ Don't transform data by copying to state** - use `select` option
|
||||
|
||||
**❌ Don't mismatch query keys** - be consistent with types (`'1'` vs `1`)
|
||||
|
||||
### Cache Timing Guidelines
|
||||
|
||||
**staleTime** - How long data is considered fresh:
|
||||
- `0` (default) - Always stale, refetch on mount/focus
|
||||
- `30_000` (30s) - Good for user-generated content
|
||||
- `120_000` (2min) - Good for profile data
|
||||
- `600_000` (10min) - Good for static reference data
|
||||
|
||||
**gcTime** (formerly cacheTime) - How long unused data stays in cache:
|
||||
- `300_000` (5min, default) - Good for most cases
|
||||
- `Infinity` - Keep forever (useful with persistence)
|
||||
- `0` - Immediate garbage collection (not recommended)
|
||||
|
||||
**Relationship**: `staleTime` controls refetch frequency, `gcTime` controls memory cleanup.
|
||||
|
||||
## Related Skills
|
||||
|
||||
- **router-query-integration** - Integrating Query with TanStack Router loaders
|
||||
- **api-integration** - Apidog + OpenAPI integration
|
||||
- **react-patterns** - Choose between Query mutations vs React Actions
|
||||
- **testing-strategy** - Advanced MSW patterns
|
||||
Reference in New Issue
Block a user