Initial commit

This commit is contained in:
Zhongwei Li
2025-11-30 09:01:30 +08:00
commit 1f69c28fe0
9 changed files with 4075 additions and 0 deletions

612
skills/skill/SKILL.md Normal file
View File

@@ -0,0 +1,612 @@
---
name: tanstack-query
description: Master TanStack Query (React Query) v5 for server state management in React applications. Use when fetching data from APIs, managing server state, caching, or handling mutations. Triggers on phrases like "react query", "tanstack query", "data fetching", "cache management", "server state", or file patterns like *query*.ts, *Query*.tsx, queryClient.ts.
---
# TanStack Query (React Query) v5
Powerful asynchronous state management for React. TanStack Query makes fetching, caching, synchronizing, and updating server state in your React applications a breeze.
## When to Use This Skill
- Fetching data from REST APIs or GraphQL endpoints
- Managing server state and cache lifecycle
- Implementing mutations (create, update, delete operations)
- Building infinite scroll or load-more patterns
- Handling optimistic UI updates
- Synchronizing data across components
- Implementing background data refetching
- Managing complex async state without Redux or other state managers
## Quick Start Workflow
### 1. Installation
```bash
npm install @tanstack/react-query
# or
pnpm add @tanstack/react-query
# or
yarn add @tanstack/react-query
```
### 2. Setup QueryClient
Wrap your application with `QueryClientProvider`:
```tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient();
function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
</QueryClientProvider>
);
}
```
### 3. Basic Query
```tsx
import { useQuery } from '@tanstack/react-query';
function TodoList() {
const { data, isLoading, error } = useQuery({
queryKey: ['todos'],
queryFn: async () => {
const res = await fetch('https://api.example.com/todos');
if (!res.ok) throw new Error('Network response was not ok');
return res.json();
},
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<ul>
{data.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
);
}
```
### 4. Basic Mutation
```tsx
import { useMutation, useQueryClient } from '@tanstack/react-query';
function CreateTodo() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: async (newTodo) => {
const res = await fetch('https://api.example.com/todos', {
method: 'POST',
body: JSON.stringify(newTodo),
headers: { 'Content-Type': 'application/json' },
});
return res.json();
},
onSuccess: () => {
// Invalidate and refetch todos
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
return (
<button onClick={() => mutation.mutate({ title: 'New Todo' })}>
{mutation.isPending ? 'Creating...' : 'Create Todo'}
</button>
);
}
```
## Core Concepts
### Query Keys
Query keys uniquely identify queries and are used for caching. They must be arrays.
```tsx
// Simple key
useQuery({ queryKey: ['todos'], queryFn: fetchTodos });
// Key with variables
useQuery({ queryKey: ['todo', todoId], queryFn: () => fetchTodo(todoId) });
// Hierarchical keys
useQuery({ queryKey: ['todos', 'list', { filters, page }], queryFn: fetchTodos });
```
**Query key matching:**
- `['todos']` - exact match
- `['todos', { page: 1 }]` - exact match with object
- `{ queryKey: ['todos'] }` - matches all queries starting with 'todos'
### Query Functions
Query functions must return a promise that resolves data or throws an error:
```tsx
// Using fetch
queryFn: async () => {
const res = await fetch(url);
if (!res.ok) throw new Error('Failed to fetch');
return res.json();
}
// Using axios
queryFn: () => axios.get(url).then(res => res.data)
// With query key access
queryFn: ({ queryKey }) => {
const [_, todoId] = queryKey;
return fetchTodo(todoId);
}
```
### Important Defaults
Understanding defaults is crucial for optimal usage:
- **staleTime: 0** - Queries become stale immediately by default
- **gcTime: 5 minutes** - Unused/inactive cache data remains in memory for 5 minutes
- **retry: 3** - Failed queries retry 3 times with exponential backoff
- **refetchOnWindowFocus: true** - Queries refetch when window regains focus
- **refetchOnReconnect: true** - Queries refetch when network reconnects
```tsx
// Override defaults globally
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 10, // 10 minutes
},
},
});
// Or per query
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
staleTime: 1000 * 60, // 1 minute
retry: 5,
});
```
### Query Status and Fetch Status
Queries have two important states:
**Query Status:**
- `pending` - No cached data, query is executing
- `error` - Query encountered an error
- `success` - Query succeeded and data is available
**Fetch Status:**
- `fetching` - Query function is executing
- `paused` - Query wants to fetch but is paused (offline)
- `idle` - Query is not fetching
```tsx
const { data, status, fetchStatus, isLoading, isFetching } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
// isLoading = status === 'pending'
// isFetching = fetchStatus === 'fetching'
```
### Query Invalidation
Mark queries as stale to trigger refetches:
```tsx
const queryClient = useQueryClient();
// Invalidate all todos queries
queryClient.invalidateQueries({ queryKey: ['todos'] });
// Invalidate specific query
queryClient.invalidateQueries({ queryKey: ['todo', todoId] });
// Invalidate and refetch immediately
queryClient.invalidateQueries({
queryKey: ['todos'],
refetchType: 'active' // only refetch active queries
});
```
### Mutations
Mutations are used for creating, updating, or deleting data:
```tsx
const mutation = useMutation({
mutationFn: (newTodo) => {
return fetch('/api/todos', {
method: 'POST',
body: JSON.stringify(newTodo),
});
},
onSuccess: (data, variables, context) => {
console.log('Success!', data);
},
onError: (error, variables, context) => {
console.error('Error:', error);
},
onSettled: (data, error, variables, context) => {
console.log('Mutation finished');
},
});
// Trigger mutation
mutation.mutate({ title: 'New Todo' });
// With async/await
mutation.mutateAsync({ title: 'New Todo' })
.then(data => console.log(data))
.catch(error => console.error(error));
```
### React Suspense Integration
TanStack Query supports React Suspense with dedicated hooks:
```tsx
import { useSuspenseQuery } from '@tanstack/react-query';
function TodoList() {
// This will suspend the component until data is ready
const { data } = useSuspenseQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
// No need for loading states - handled by Suspense boundary
return (
<ul>
{data.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
);
}
// In parent component
function App() {
return (
<Suspense fallback={<div>Loading todos...</div>}>
<TodoList />
</Suspense>
);
}
```
## Advanced Topics
For detailed information on advanced patterns, see the reference files:
### Infinite Queries
For implementing infinite scroll and load-more patterns:
- See `references/infinite-queries.md` for comprehensive guide
- Covers `useInfiniteQuery` hook
- Bidirectional pagination
- `getNextPageParam` and `getPreviousPageParam`
- Refetching and background updates
### Optimistic Updates
For updating UI before server confirmation:
- See `references/optimistic-updates.md` for detailed patterns
- Optimistic mutations
- Rollback on error
- Context for cancellation
- UI feedback strategies
### TypeScript Support
For full type safety and inference:
- See `references/typescript.md` for complete TypeScript guide
- Type inference from query functions
- Generic type parameters
- Typing query options
- Custom hooks with types
- Error type narrowing
### Query Invalidation Patterns
For advanced cache invalidation strategies:
- See `references/query-invalidation.md`
- Partial matching
- Predicate functions
- Refetch strategies
- Query filters
### Performance Optimization
For optimizing query performance:
- See `references/performance.md`
- Query deduplication
- Structural sharing
- Memory management
- Query splitting strategies
## DevTools
TanStack Query DevTools provide visual insights into query state:
```bash
npm install @tanstack/react-query-devtools
```
```tsx
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
```
**DevTools features:**
- View all queries and their states
- Inspect query data and errors
- Manually trigger refetches
- Invalidate queries
- Monitor cache lifecycle
## Common Patterns
### Dependent Queries
Run queries in sequence when one depends on another:
```tsx
// First query
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
// Second query depends on first
const { data: projects } = useQuery({
queryKey: ['projects', user?.id],
queryFn: () => fetchProjects(user.id),
enabled: !!user?.id, // Only run when user.id is available
});
```
### Parallel Queries
Multiple independent queries in one component:
```tsx
function Dashboard() {
const users = useQuery({ queryKey: ['users'], queryFn: fetchUsers });
const posts = useQuery({ queryKey: ['posts'], queryFn: fetchPosts });
const stats = useQuery({ queryKey: ['stats'], queryFn: fetchStats });
if (users.isLoading || posts.isLoading || stats.isLoading) {
return <div>Loading...</div>;
}
// All queries succeeded
return <DashboardView users={users.data} posts={posts.data} stats={stats.data} />;
}
```
### Dynamic Parallel Queries
Use `useQueries` for dynamic number of queries:
```tsx
import { useQueries } from '@tanstack/react-query';
function TodoLists({ listIds }) {
const results = useQueries({
queries: listIds.map((id) => ({
queryKey: ['list', id],
queryFn: () => fetchList(id),
})),
});
const isLoading = results.some(result => result.isLoading);
const data = results.map(result => result.data);
return <Lists data={data} />;
}
```
### Prefetching
Prefetch data before it's needed:
```tsx
const queryClient = useQueryClient();
// Prefetch on hover
function TodoListLink({ id }) {
const prefetch = () => {
queryClient.prefetchQuery({
queryKey: ['todo', id],
queryFn: () => fetchTodo(id),
staleTime: 1000 * 60 * 5, // Cache for 5 minutes
});
};
return (
<Link to={`/todo/${id}`} onMouseEnter={prefetch}>
View Todo
</Link>
);
}
```
### Initial Data
Provide initial data to avoid loading states:
```tsx
function TodoDetail({ todoId, initialTodo }) {
const { data } = useQuery({
queryKey: ['todo', todoId],
queryFn: () => fetchTodo(todoId),
initialData: initialTodo, // Use this data immediately
staleTime: 1000 * 60, // Consider fresh for 1 minute
});
return <div>{data.title}</div>;
}
```
### Placeholder Data
Show placeholder while loading:
```tsx
const { data, isPlaceholderData } = useQuery({
queryKey: ['todos', page],
queryFn: () => fetchTodos(page),
placeholderData: (previousData) => previousData, // Keep previous data while loading
});
// Or use static placeholder
const { data } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
placeholderData: { items: [], total: 0 },
});
```
## Error Handling
### Query Errors
```tsx
const { error, isError } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
retry: 3,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
});
if (isError) {
return <div>Error: {error.message}</div>;
}
```
### Global Error Handling
```tsx
const queryClient = new QueryClient({
defaultOptions: {
queries: {
onError: (error) => {
console.error('Query error:', error);
// Show toast notification, etc.
},
},
mutations: {
onError: (error) => {
console.error('Mutation error:', error);
},
},
},
});
```
### Error Boundaries
Combine with React Error Boundaries:
```tsx
import { useQuery } from '@tanstack/react-query';
import { ErrorBoundary } from 'react-error-boundary';
function TodoList() {
const { data } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
throwOnError: true, // Throw errors to error boundary
});
return <div>{/* render data */}</div>;
}
function App() {
return (
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<TodoList />
</ErrorBoundary>
);
}
```
## Best Practices
1. **Use Query Keys Wisely**
- Structure keys hierarchically: `['todos', 'list', { filters }]`
- Include all variables in the key
- Keep keys consistent across your app
2. **Set Appropriate staleTime**
- Static data: `staleTime: Infinity`
- Frequently changing: `staleTime: 0` (default)
- Moderately changing: `staleTime: 1000 * 60 * 5` (5 minutes)
3. **Handle Loading and Error States**
- Always check `isLoading` and `error`
- Provide meaningful loading indicators
- Show user-friendly error messages
4. **Optimize Refetching**
- Disable unnecessary refetches with `refetchOnWindowFocus: false`
- Use `staleTime` to reduce refetches
- Consider using `refetchInterval` for polling
5. **Invalidate Efficiently**
- Invalidate specific queries, not all queries
- Use query key prefixes for related queries
- Combine with optimistic updates for better UX
6. **Use TypeScript**
- Type your query functions for type inference
- Use generic type parameters when needed
- Enable strict type checking
7. **Leverage DevTools**
- Install DevTools in development
- Monitor query behavior
- Debug cache issues
## Resources
- **Official Documentation**: https://tanstack.com/query/latest/docs/framework/react/overview
- **GitHub Repository**: https://github.com/TanStack/query
- **Examples**: https://tanstack.com/query/latest/docs/framework/react/examples
- **Community**: https://discord.gg/tanstack
- **TypeScript Guide**: https://tanstack.com/query/latest/docs/framework/react/typescript
## Migration from v4
If you're upgrading from React Query v4:
- `cacheTime` renamed to `gcTime`
- `useInfiniteQuery` pageParam changes
- New `useSuspenseQuery` hooks
- Improved TypeScript inference
- See official migration guide: https://tanstack.com/query/latest/docs/framework/react/guides/migrating-to-v5

View File

@@ -0,0 +1,653 @@
# Infinite Queries
Infinite queries are used for implementing "load more" and infinite scroll patterns. They allow you to fetch paginated data progressively.
## Basic Infinite Query
```tsx
import { useInfiniteQuery } from '@tanstack/react-query';
function Posts() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: async ({ pageParam = 0 }) => {
const res = await fetch(`/api/posts?cursor=${pageParam}`);
return res.json();
},
initialPageParam: 0,
getNextPageParam: (lastPage, allPages) => {
// Return undefined if no more pages
return lastPage.nextCursor ?? undefined;
},
});
if (isLoading) return <div>Loading...</div>;
return (
<>
{data.pages.map((page, i) => (
<div key={i}>
{page.posts.map((post) => (
<div key={post.id}>{post.title}</div>
))}
</div>
))}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage
? 'Loading more...'
: hasNextPage
? 'Load More'
: 'Nothing more to load'}
</button>
</>
);
}
```
## Data Structure
The `data` object has a specific structure:
```tsx
{
pages: [
{ posts: [...], nextCursor: 1 }, // Page 1
{ posts: [...], nextCursor: 2 }, // Page 2
{ posts: [...], nextCursor: 3 }, // Page 3
],
pageParams: [0, 1, 2] // The pageParam values used
}
```
## Page Parameters
### initialPageParam
Required parameter that specifies the initial page parameter:
```tsx
useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam }) => fetchPosts(pageParam),
initialPageParam: 0, // or 1, or { cursor: null }, etc.
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
```
### getNextPageParam
Function that receives the last page and all pages, and returns the next page parameter:
```tsx
// Cursor-based pagination
getNextPageParam: (lastPage, allPages) => {
return lastPage.nextCursor ?? undefined;
}
// Offset-based pagination
getNextPageParam: (lastPage, allPages) => {
if (lastPage.length === 0) return undefined;
return allPages.length * 10; // Assuming 10 items per page
}
// Page number pagination
getNextPageParam: (lastPage, allPages) => {
const totalPages = lastPage.totalPages;
const nextPage = allPages.length + 1;
return nextPage <= totalPages ? nextPage : undefined;
}
// Access to all pages
getNextPageParam: (lastPage, allPages) => {
const totalFetched = allPages.reduce((acc, page) => acc + page.data.length, 0);
return totalFetched < lastPage.total ? lastPage.nextCursor : undefined;
}
```
### getPreviousPageParam
For bidirectional infinite scrolling:
```tsx
useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam }) => fetchPosts(pageParam),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
getPreviousPageParam: (firstPage) => firstPage.previousCursor,
});
```
## Fetching Pages
### Fetch Next Page
```tsx
const { fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
// ...config
});
// Manual trigger
<button onClick={() => fetchNextPage()} disabled={!hasNextPage}>
Load More
</button>
// Infinite scroll
useEffect(() => {
const handleScroll = () => {
if (
window.innerHeight + window.scrollY >= document.body.offsetHeight - 500 &&
hasNextPage &&
!isFetchingNextPage
) {
fetchNextPage();
}
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
```
### Fetch Previous Page
```tsx
const { fetchPreviousPage, hasPreviousPage, isFetchingPreviousPage } = useInfiniteQuery({
// ...config
});
<button onClick={() => fetchPreviousPage()} disabled={!hasPreviousPage}>
Load Previous
</button>
```
## Pagination Strategies
### Cursor-Based Pagination
Best for real-time data and when items can be inserted/deleted:
```tsx
useInfiniteQuery({
queryKey: ['posts'],
queryFn: async ({ pageParam }) => {
const res = await fetch(`/api/posts?cursor=${pageParam}&limit=10`);
return res.json();
},
initialPageParam: null,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
});
// API response structure:
// {
// data: [...],
// nextCursor: 'cursor_string' | null
// }
```
### Offset-Based Pagination
Simpler but can have issues with real-time data:
```tsx
useInfiniteQuery({
queryKey: ['posts'],
queryFn: async ({ pageParam = 0 }) => {
const res = await fetch(`/api/posts?offset=${pageParam}&limit=10`);
return res.json();
},
initialPageParam: 0,
getNextPageParam: (lastPage, allPages) => {
if (lastPage.data.length < 10) return undefined;
return allPages.length * 10;
},
});
```
### Page Number Pagination
Traditional page-based approach:
```tsx
useInfiniteQuery({
queryKey: ['posts'],
queryFn: async ({ pageParam = 1 }) => {
const res = await fetch(`/api/posts?page=${pageParam}&size=10`);
return res.json();
},
initialPageParam: 1,
getNextPageParam: (lastPage, allPages) => {
const currentPage = allPages.length;
return currentPage < lastPage.totalPages ? currentPage + 1 : undefined;
},
});
```
## Infinite Scroll Implementation
### Using Intersection Observer
```tsx
import { useInfiniteQuery } from '@tanstack/react-query';
import { useRef, useCallback } from 'react';
function InfiniteScrollPosts() {
const observerTarget = useRef(null);
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam = 0 }) => fetchPosts(pageParam),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
// Setup intersection observer
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
{ threshold: 1.0 }
);
if (observerTarget.current) {
observer.observe(observerTarget.current);
}
return () => observer.disconnect();
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
return (
<div>
{data?.pages.map((page, i) => (
<div key={i}>
{page.posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
))}
{hasNextPage && (
<div ref={observerTarget} className="loading-indicator">
{isFetchingNextPage ? 'Loading more...' : 'Load more'}
</div>
)}
</div>
);
}
```
### Using react-intersection-observer
```tsx
import { useInfiniteQuery } from '@tanstack/react-query';
import { useInView } from 'react-intersection-observer';
function InfiniteScrollPosts() {
const { ref, inView } = useInView();
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam = 0 }) => fetchPosts(pageParam),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [inView, fetchNextPage, hasNextPage, isFetchingNextPage]);
return (
<div>
{data?.pages.map((page, i) => (
<div key={i}>
{page.posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
))}
<div ref={ref}>{isFetchingNextPage && 'Loading...'}</div>
</div>
);
}
```
## Refetching and Invalidation
### Refetch All Pages
```tsx
const queryClient = useQueryClient();
// Refetch all pages
queryClient.invalidateQueries({ queryKey: ['posts'] });
// Or manually
const { refetch } = useInfiniteQuery({ /* ... */ });
refetch();
```
### Refetch Only First Page
```tsx
queryClient.invalidateQueries({
queryKey: ['posts'],
refetchPage: (page, index) => index === 0,
});
```
### Refetch Specific Pages
```tsx
// Refetch first 3 pages
queryClient.invalidateQueries({
queryKey: ['posts'],
refetchPage: (page, index) => index < 3,
});
// Refetch based on page content
queryClient.invalidateQueries({
queryKey: ['posts'],
refetchPage: (page, index) => {
// Refetch if page has a specific item
return page.posts.some(post => post.id === targetId);
},
});
```
## Transforming Data
### Flatten Pages
```tsx
const { data } = useInfiniteQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
select: (data) => ({
pages: [...data.pages],
pageParams: [...data.pageParams],
// Flatten all posts
allPosts: data.pages.flatMap(page => page.posts),
}),
});
// Now you can use data.allPosts directly
return <div>{data?.allPosts.map(post => <PostCard key={post.id} post={post} />)}</div>;
```
### Filter and Transform
```tsx
const { data } = useInfiniteQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
select: (data) => ({
...data,
pages: data.pages.map(page => ({
...page,
posts: page.posts.filter(post => !post.isDeleted),
})),
}),
});
```
## Bidirectional Infinite Scrolling
```tsx
function BidirectionalScroll() {
const {
data,
fetchNextPage,
fetchPreviousPage,
hasNextPage,
hasPreviousPage,
isFetchingNextPage,
isFetchingPreviousPage,
} = useInfiniteQuery({
queryKey: ['messages'],
queryFn: ({ pageParam }) => fetchMessages(pageParam),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
getPreviousPageParam: (firstPage) => firstPage.previousCursor,
});
return (
<div>
<button
onClick={() => fetchPreviousPage()}
disabled={!hasPreviousPage || isFetchingPreviousPage}
>
{isFetchingPreviousPage ? 'Loading...' : 'Load Older'}
</button>
{data?.pages.map((page, i) => (
<div key={i}>
{page.messages.map((message) => (
<MessageCard key={message.id} message={message} />
))}
</div>
))}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading...' : 'Load Newer'}
</button>
</div>
);
}
```
## Advanced Patterns
### Search with Infinite Scroll
```tsx
function SearchResults({ searchTerm }) {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['search', searchTerm],
queryFn: ({ pageParam = 0 }) =>
fetch(`/api/search?q=${searchTerm}&cursor=${pageParam}`).then(r => r.json()),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
enabled: searchTerm.length > 2, // Only search if term is long enough
});
return (
<div>
{data?.pages.map((page, i) => (
<div key={i}>
{page.results.map((result) => (
<SearchResult key={result.id} result={result} />
))}
</div>
))}
{hasNextPage && (
<button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
Load More Results
</button>
)}
</div>
);
}
```
### Infinite Query with Filters
```tsx
function FilteredList({ filters }) {
const {
data,
fetchNextPage,
hasNextPage,
} = useInfiniteQuery({
queryKey: ['items', filters],
queryFn: ({ pageParam = 0 }) =>
fetchItems({ ...filters, cursor: pageParam }),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
// When filters change, query automatically resets and refetches from page 1
return (
<div>
{data?.pages.map((page, i) => (
<div key={i}>
{page.items.map((item) => (
<ItemCard key={item.id} item={item} />
))}
</div>
))}
{hasNextPage && <button onClick={() => fetchNextPage()}>Load More</button>}
</div>
);
}
```
### Prefetching Next Page
```tsx
function Posts() {
const queryClient = useQueryClient();
const {
data,
fetchNextPage,
hasNextPage,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam = 0 }) => fetchPosts(pageParam),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
// Prefetch next page when user is near the end
useEffect(() => {
if (hasNextPage) {
const nextPageParam = data?.pageParams[data.pageParams.length - 1] + 1;
queryClient.prefetchInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam }) => fetchPosts(pageParam),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
pages: data?.pages.length + 1, // Prefetch one more page
});
}
}, [data, hasNextPage, queryClient]);
return <div>{/* render */}</div>;
}
```
## Common Issues and Solutions
### Duplicate Data After Invalidation
When invalidating an infinite query, it refetches all pages. To avoid duplicates:
```tsx
// Option 1: Use refetchPage to only refetch specific pages
queryClient.invalidateQueries({
queryKey: ['posts'],
refetchPage: (page, index) => index === 0, // Only refetch first page
});
// Option 2: Reset to first page
queryClient.resetQueries({ queryKey: ['posts'] });
```
### Stale Data Between Pages
Set appropriate `staleTime`:
```tsx
useInfiniteQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
staleTime: 1000 * 60 * 5, // 5 minutes
});
```
### Managing Total Count
Track total items across all pages:
```tsx
const { data } = useInfiniteQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
select: (data) => ({
...data,
totalCount: data.pages[0]?.total || 0, // Assuming API returns total
currentCount: data.pages.reduce((acc, page) => acc + page.posts.length, 0),
}),
});
// Display: Showing {data.currentCount} of {data.totalCount}
```
## Best Practices
1. **Choose the Right Pagination Strategy**
- Use cursor-based for real-time feeds
- Use offset for simple lists
- Use page numbers for traditional pagination
2. **Handle Edge Cases**
- Empty states when no data
- Loading states for first page
- Error states with retry
- End of list indicators
3. **Optimize Performance**
- Use `select` to transform data once
- Set appropriate `staleTime` and `gcTime`
- Implement virtual scrolling for large lists (react-window, react-virtualized)
4. **Refetch Strategies**
- Only refetch first page for most updates
- Use `refetchPage` for targeted refetches
- Consider resetting queries when filters change
5. **User Experience**
- Show loading indicators for next/previous pages
- Disable buttons during fetching
- Provide feedback when no more data
- Handle errors gracefully with retry options

View File

@@ -0,0 +1,616 @@
# Optimistic Updates
Optimistic updates allow you to update the UI immediately before a mutation completes, providing a better user experience. If the mutation fails, you can roll back to the previous state.
## Basic Optimistic Update
```tsx
import { useMutation, useQueryClient } from '@tanstack/react-query';
function TodoList() {
const queryClient = useQueryClient();
const toggleTodo = useMutation({
mutationFn: (todoId) => {
return fetch(`/api/todos/${todoId}/toggle`, { method: 'POST' });
},
onMutate: async (todoId) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['todos'] });
// Snapshot the previous value
const previousTodos = queryClient.getQueryData(['todos']);
// Optimistically update
queryClient.setQueryData(['todos'], (old) =>
old.map((todo) =>
todo.id === todoId ? { ...todo, done: !todo.done } : todo
)
);
// Return context with previous value
return { previousTodos };
},
onError: (err, todoId, context) => {
// Rollback on error
queryClient.setQueryData(['todos'], context.previousTodos);
},
onSettled: () => {
// Refetch after mutation completes
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
return (
<div>
{/* render todos with toggle */}
<button onClick={() => toggleTodo.mutate(todoId)}>Toggle</button>
</div>
);
}
```
## Mutation Lifecycle
Understanding the mutation lifecycle is crucial for optimistic updates:
```tsx
const mutation = useMutation({
mutationFn: updateTodo,
// 1. Before mutation function runs
onMutate: async (variables) => {
// Cancel queries, snapshot data, optimistically update
// Return context object
return { previousData };
},
// 2. If mutation succeeds
onSuccess: (data, variables, context) => {
// Handle successful mutation
// data = mutation function response
// variables = mutation variables
// context = returned from onMutate
},
// 3. If mutation fails
onError: (error, variables, context) => {
// Rollback optimistic update
// error = error object
// context = returned from onMutate
},
// 4. Always runs after success or error
onSettled: (data, error, variables, context) => {
// Refetch to sync with server
},
});
```
## Optimistic Update Patterns
### Adding an Item
```tsx
const addTodo = useMutation({
mutationFn: (newTodo) => {
return fetch('/api/todos', {
method: 'POST',
body: JSON.stringify(newTodo),
}).then(res => res.json());
},
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previousTodos = queryClient.getQueryData(['todos']);
// Add optimistic todo with temporary ID
queryClient.setQueryData(['todos'], (old) => [
...old,
{ ...newTodo, id: 'temp-' + Date.now(), status: 'pending' },
]);
return { previousTodos };
},
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context.previousTodos);
},
onSuccess: (data) => {
// Replace temporary item with real server response
queryClient.setQueryData(['todos'], (old) =>
old.map((todo) =>
todo.id.toString().startsWith('temp-') ? data : todo
)
);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
```
### Updating an Item
```tsx
const updateTodo = useMutation({
mutationFn: ({ id, updates }) => {
return fetch(`/api/todos/${id}`, {
method: 'PATCH',
body: JSON.stringify(updates),
}).then(res => res.json());
},
onMutate: async ({ id, updates }) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previousTodos = queryClient.getQueryData(['todos']);
queryClient.setQueryData(['todos'], (old) =>
old.map((todo) =>
todo.id === id ? { ...todo, ...updates } : todo
)
);
return { previousTodos };
},
onError: (err, variables, context) => {
queryClient.setQueryData(['todos'], context.previousTodos);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
// Usage
updateTodo.mutate({ id: 1, updates: { title: 'Updated title' } });
```
### Deleting an Item
```tsx
const deleteTodo = useMutation({
mutationFn: (todoId) => {
return fetch(`/api/todos/${todoId}`, { method: 'DELETE' });
},
onMutate: async (todoId) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previousTodos = queryClient.getQueryData(['todos']);
queryClient.setQueryData(['todos'], (old) =>
old.filter((todo) => todo.id !== todoId)
);
return { previousTodos };
},
onError: (err, todoId, context) => {
queryClient.setQueryData(['todos'], context.previousTodos);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
```
## Multiple Query Updates
Update multiple related queries optimistically:
```tsx
const updateUser = useMutation({
mutationFn: ({ userId, updates }) => {
return fetch(`/api/users/${userId}`, {
method: 'PATCH',
body: JSON.stringify(updates),
}).then(res => res.json());
},
onMutate: async ({ userId, updates }) => {
// Cancel all related queries
await queryClient.cancelQueries({ queryKey: ['users'] });
await queryClient.cancelQueries({ queryKey: ['user', userId] });
// Snapshot previous data
const previousUsers = queryClient.getQueryData(['users']);
const previousUser = queryClient.getQueryData(['user', userId]);
// Update users list
queryClient.setQueryData(['users'], (old) =>
old?.map((user) =>
user.id === userId ? { ...user, ...updates } : user
)
);
// Update individual user
queryClient.setQueryData(['user', userId], (old) => ({
...old,
...updates,
}));
return { previousUsers, previousUser };
},
onError: (err, { userId }, context) => {
// Rollback both queries
queryClient.setQueryData(['users'], context.previousUsers);
queryClient.setQueryData(['user', userId], context.previousUser);
},
onSettled: (data, error, { userId }) => {
queryClient.invalidateQueries({ queryKey: ['users'] });
queryClient.invalidateQueries({ queryKey: ['user', userId] });
},
});
```
## Optimistic Updates with Infinite Queries
```tsx
const addPost = useMutation({
mutationFn: (newPost) => {
return fetch('/api/posts', {
method: 'POST',
body: JSON.stringify(newPost),
}).then(res => res.json());
},
onMutate: async (newPost) => {
await queryClient.cancelQueries({ queryKey: ['posts'] });
const previousPosts = queryClient.getQueryData(['posts']);
// Add to first page
queryClient.setQueryData(['posts'], (old) => {
if (!old?.pages.length) return old;
return {
...old,
pages: [
{
...old.pages[0],
posts: [
{ ...newPost, id: 'temp-' + Date.now() },
...old.pages[0].posts,
],
},
...old.pages.slice(1),
],
};
});
return { previousPosts };
},
onError: (err, newPost, context) => {
queryClient.setQueryData(['posts'], context.previousPosts);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
```
## UI Feedback During Optimistic Updates
### Show Pending State
```tsx
function TodoItem({ todo }) {
const queryClient = useQueryClient();
const toggleTodo = useMutation({
mutationFn: (todoId) => fetch(`/api/todos/${todoId}/toggle`, { method: 'POST' }),
onMutate: async (todoId) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previousTodos = queryClient.getQueryData(['todos']);
queryClient.setQueryData(['todos'], (old) =>
old.map((t) =>
t.id === todoId
? { ...t, done: !t.done, isPending: true } // Mark as pending
: t
)
);
return { previousTodos };
},
onSuccess: (data, todoId) => {
// Remove pending state
queryClient.setQueryData(['todos'], (old) =>
old.map((t) =>
t.id === todoId ? { ...t, isPending: false } : t
)
);
},
onError: (err, todoId, context) => {
queryClient.setQueryData(['todos'], context.previousTodos);
},
});
return (
<div className={todo.isPending ? 'opacity-50' : ''}>
<input
type="checkbox"
checked={todo.done}
onChange={() => toggleTodo.mutate(todo.id)}
disabled={todo.isPending}
/>
{todo.title}
</div>
);
}
```
### Show Error State
```tsx
const [error, setError] = useState(null);
const updateTodo = useMutation({
mutationFn: updateTodoApi,
onMutate: async (updates) => {
setError(null); // Clear previous errors
// ... optimistic update
},
onError: (err, variables, context) => {
setError(err.message);
// ... rollback
},
});
return (
<div>
{error && <div className="error">{error}</div>}
{/* render todo */}
</div>
);
```
## Advanced Patterns
### Optimistic Update with Retry
```tsx
const updateTodo = useMutation({
mutationFn: updateTodoApi,
retry: 3,
onMutate: async (updates) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previousTodos = queryClient.getQueryData(['todos']);
queryClient.setQueryData(['todos'], (old) =>
old.map((todo) =>
todo.id === updates.id
? { ...todo, ...updates, _optimistic: true }
: todo
)
);
return { previousTodos };
},
onSuccess: (data, variables) => {
// Remove optimistic flag
queryClient.setQueryData(['todos'], (old) =>
old.map((todo) =>
todo.id === variables.id
? { ...todo, _optimistic: false }
: todo
)
);
},
onError: (err, variables, context) => {
// Only rollback if all retries failed
if (err.retryCount >= 3) {
queryClient.setQueryData(['todos'], context.previousTodos);
}
},
});
```
### Debounced Optimistic Updates
For rapid updates like typing in a search or editing text:
```tsx
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useDebouncedCallback } from 'use-debounce';
function TodoTitle({ todo }) {
const queryClient = useQueryClient();
const [localTitle, setLocalTitle] = useState(todo.title);
const updateTodo = useMutation({
mutationFn: ({ id, title }) => {
return fetch(`/api/todos/${id}`, {
method: 'PATCH',
body: JSON.stringify({ title }),
}).then(res => res.json());
},
onMutate: async ({ id, title }) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previousTodos = queryClient.getQueryData(['todos']);
queryClient.setQueryData(['todos'], (old) =>
old.map((t) => (t.id === id ? { ...t, title } : t))
);
return { previousTodos };
},
onError: (err, variables, context) => {
queryClient.setQueryData(['todos'], context.previousTodos);
setLocalTitle(context.previousTodos.find(t => t.id === variables.id).title);
},
});
const debouncedUpdate = useDebouncedCallback(
(id, title) => updateTodo.mutate({ id, title }),
500
);
const handleChange = (e) => {
const newTitle = e.target.value;
setLocalTitle(newTitle);
debouncedUpdate(todo.id, newTitle);
};
return <input value={localTitle} onChange={handleChange} />;
}
```
### Optimistic Delete with Undo
```tsx
function TodoItem({ todo }) {
const queryClient = useQueryClient();
const [showUndo, setShowUndo] = useState(false);
const deleteTodo = useMutation({
mutationFn: (todoId) => {
return fetch(`/api/todos/${todoId}`, { method: 'DELETE' });
},
onMutate: async (todoId) => {
setShowUndo(true);
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previousTodos = queryClient.getQueryData(['todos']);
queryClient.setQueryData(['todos'], (old) =>
old.filter((t) => t.id !== todoId)
);
// Auto-hide undo after 5 seconds
setTimeout(() => setShowUndo(false), 5000);
return { previousTodos };
},
onError: (err, todoId, context) => {
queryClient.setQueryData(['todos'], context.previousTodos);
setShowUndo(false);
},
});
const handleUndo = () => {
deleteTodo.reset(); // Reset mutation state
queryClient.invalidateQueries({ queryKey: ['todos'] });
setShowUndo(false);
};
if (showUndo) {
return (
<div className="undo-banner">
Todo deleted <button onClick={handleUndo}>Undo</button>
</div>
);
}
return (
<div>
{todo.title}
<button onClick={() => deleteTodo.mutate(todo.id)}>Delete</button>
</div>
);
}
```
### Batch Optimistic Updates
```tsx
const markAllDone = useMutation({
mutationFn: () => {
return fetch('/api/todos/mark-all-done', { method: 'POST' });
},
onMutate: async () => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previousTodos = queryClient.getQueryData(['todos']);
queryClient.setQueryData(['todos'], (old) =>
old.map((todo) => ({ ...todo, done: true }))
);
return { previousTodos };
},
onError: (err, variables, context) => {
queryClient.setQueryData(['todos'], context.previousTodos);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
```
## Cancel In-Flight Mutations
Cancel mutations that are no longer needed:
```tsx
function QuickEdit({ todo }) {
const queryClient = useQueryClient();
const updateTodo = useMutation({
mutationFn: ({ id, title }) => {
return fetch(`/api/todos/${id}`, {
method: 'PATCH',
body: JSON.stringify({ title }),
}).then(res => res.json());
},
onMutate: async ({ id, title }) => {
// Cancel previous mutations for this todo
queryClient.cancelMutations({ mutationKey: ['updateTodo', id] });
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previousTodos = queryClient.getQueryData(['todos']);
queryClient.setQueryData(['todos'], (old) =>
old.map((t) => (t.id === id ? { ...t, title } : t))
);
return { previousTodos };
},
});
return (
<input
onChange={(e) => updateTodo.mutate({ id: todo.id, title: e.target.value })}
/>
);
}
```
## Best Practices
1. **Always Cancel Queries**
```tsx
await queryClient.cancelQueries({ queryKey: ['todos'] });
```
Prevents race conditions between optimistic update and ongoing fetches.
2. **Always Return Context**
```tsx
onMutate: async (variables) => {
const previousData = queryClient.getQueryData(['todos']);
// ... update
return { previousData }; // Critical for rollback
}
```
3. **Always Handle Errors**
```tsx
onError: (err, variables, context) => {
queryClient.setQueryData(['todos'], context.previousData);
}
```
4. **Use onSettled for Refetch**
```tsx
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
}
```
Ensures data stays in sync with server.
5. **Show Visual Feedback**
- Add loading/pending states to optimistically updated items
- Show error messages on failure
- Provide undo functionality where appropriate
6. **Handle Multiple Related Queries**
- Update all queries that display the same data
- Rollback all queries on error
7. **Consider Using Temporary IDs**
- For created items, use temp IDs until server responds
- Replace with server IDs on success
8. **Test Error Cases**
- Verify rollback works correctly
- Test network failures
- Test validation errors from server

View File

@@ -0,0 +1,653 @@
# Performance Optimization
Optimize TanStack Query for better performance, reduced network requests, and improved user experience.
## Query Configuration
### staleTime
Control how long data is considered fresh:
```tsx
// ❌ Default - data stale immediately
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
staleTime: 0, // default
});
// ✅ Optimized - data fresh for 5 minutes
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
staleTime: 1000 * 60 * 5, // 5 minutes
});
// ✅ Static data - never stale
useQuery({
queryKey: ['config'],
queryFn: fetchConfig,
staleTime: Infinity,
});
```
### gcTime (formerly cacheTime)
Control how long unused data stays in cache:
```tsx
// Default - 5 minutes
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
gcTime: 1000 * 60 * 5, // default
});
// Extended cache for frequently accessed data
useQuery({
queryKey: ['user-profile'],
queryFn: fetchUserProfile,
gcTime: 1000 * 60 * 30, // 30 minutes
});
// Immediate cleanup for sensitive data
useQuery({
queryKey: ['payment-info'],
queryFn: fetchPaymentInfo,
gcTime: 0, // Remove immediately when unused
});
```
### Disable Unnecessary Refetching
```tsx
// Disable all automatic refetching
useQuery({
queryKey: ['static-data'],
queryFn: fetchStaticData,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
});
// Global defaults
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
},
});
```
## Query Deduplication
TanStack Query automatically deduplicates identical requests:
```tsx
// These three components all request the same data
function Component1() {
useQuery({ queryKey: ['user', userId], queryFn: fetchUser });
}
function Component2() {
useQuery({ queryKey: ['user', userId], queryFn: fetchUser });
}
function Component3() {
useQuery({ queryKey: ['user', userId], queryFn: fetchUser });
}
// Result: Only ONE network request is made
// All three components share the same cached data
```
## Structural Sharing
TanStack Query preserves referential equality when data hasn't changed:
```tsx
const { data } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
// Structural sharing is enabled by default
structuralSharing: true,
});
// If server returns identical data, React won't re-render
// because data reference hasn't changed
```
Disable for very large datasets:
```tsx
useQuery({
queryKey: ['large-dataset'],
queryFn: fetchLargeDataset,
structuralSharing: false, // Skip structural sharing for performance
});
```
## Prefetching
Load data before it's needed:
### Hover Prefetch
```tsx
const queryClient = useQueryClient();
function TodoListItem({ todo }) {
const prefetchTodo = () => {
queryClient.prefetchQuery({
queryKey: ['todo', todo.id],
queryFn: () => fetchTodo(todo.id),
staleTime: 1000 * 60 * 5,
});
};
return (
<Link
to={`/todo/${todo.id}`}
onMouseEnter={prefetchTodo}
onFocus={prefetchTodo}
>
{todo.title}
</Link>
);
}
```
### Route-Based Prefetch
```tsx
// In router loader or component
async function todoLoader({ params }) {
await queryClient.prefetchQuery({
queryKey: ['todo', params.id],
queryFn: () => fetchTodo(params.id),
});
}
// Or in a parent component
function TodoLayout() {
const navigate = useNavigate();
useEffect(() => {
// Prefetch common routes
queryClient.prefetchQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
}, []);
return <Outlet />;
}
```
### Predictive Prefetch
```tsx
function PaginatedList({ page }) {
const { data } = useQuery({
queryKey: ['items', page],
queryFn: () => fetchItems(page),
});
// Prefetch next page
useEffect(() => {
if (page < totalPages) {
queryClient.prefetchQuery({
queryKey: ['items', page + 1],
queryFn: () => fetchItems(page + 1),
});
}
}, [page]);
return <div>{/* render items */}</div>;
}
```
## Data Transformation
### Use select for Transformation
```tsx
// ❌ Transform in component - runs on every render
function TodoList() {
const { data } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
const completedTodos = data?.filter(todo => todo.completed);
return <div>{completedTodos?.map(/* ... */)}</div>;
}
// ✅ Transform with select - memoized automatically
function TodoList() {
const { data: completedTodos } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: (todos) => todos.filter(todo => todo.completed),
});
return <div>{completedTodos?.map(/* ... */)}</div>;
}
```
### Select is Memoized
```tsx
// select function only runs when data changes
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: (todos) => {
console.log('Transforming...'); // Only logs when data changes
return todos.map(todo => ({
...todo,
displayName: `${todo.id}: ${todo.title}`,
}));
},
});
```
## Pagination Optimization
### Offset Pagination
```tsx
function PaginatedTodos() {
const [page, setPage] = useState(1);
const { data } = useQuery({
queryKey: ['todos', page],
queryFn: () => fetchTodos(page),
staleTime: 1000 * 60 * 5, // Keep pages fresh
placeholderData: (previousData) => previousData, // Keep previous data while loading
});
return (
<div>
{data?.items.map(todo => <TodoItem key={todo.id} todo={todo} />)}
<button onClick={() => setPage(p => p - 1)} disabled={page === 1}>
Previous
</button>
<button onClick={() => setPage(p => p + 1)}>
Next
</button>
</div>
);
}
```
### Infinite Queries with Windowing
For very long lists, use virtual scrolling:
```tsx
import { useInfiniteQuery } from '@tanstack/react-query';
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualizedInfiniteList() {
const {
data,
fetchNextPage,
hasNextPage,
} = useInfiniteQuery({
queryKey: ['items'],
queryFn: ({ pageParam = 0 }) => fetchItems(pageParam),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
const allItems = data?.pages.flatMap(page => page.items) ?? [];
const parentRef = useRef(null);
const virtualizer = useVirtualizer({
count: hasNextPage ? allItems.length + 1 : allItems.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 100,
});
useEffect(() => {
const lastItem = virtualizer.getVirtualItems()[virtualizer.getVirtualItems().length - 1];
if (!lastItem) return;
if (lastItem.index >= allItems.length - 1 && hasNextPage) {
fetchNextPage();
}
}, [hasNextPage, fetchNextPage, allItems.length, virtualizer.getVirtualItems()]);
return (
<div ref={parentRef} style={{ height: '500px', overflow: 'auto' }}>
<div style={{ height: `${virtualizer.getTotalSize()}px` }}>
{virtualizer.getVirtualItems().map((virtualRow) => (
<div
key={virtualRow.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{allItems[virtualRow.index] ? (
<Item item={allItems[virtualRow.index]} />
) : (
'Loading...'
)}
</div>
))}
</div>
</div>
);
}
```
## Parallel Queries Optimization
### Using useQueries
```tsx
// ❌ Sequential queries
async function fetchAllData() {
const users = await fetchUsers();
const posts = await fetchPosts();
const comments = await fetchComments();
return { users, posts, comments };
}
// ✅ Parallel queries
function Dashboard() {
const results = useQueries({
queries: [
{ queryKey: ['users'], queryFn: fetchUsers },
{ queryKey: ['posts'], queryFn: fetchPosts },
{ queryKey: ['comments'], queryFn: fetchComments },
],
});
const [users, posts, comments] = results;
const isLoading = results.some(r => r.isLoading);
return <div>{/* render */}</div>;
}
```
### Dynamic Parallel Queries
```tsx
function UserPosts({ userIds }) {
const queries = useQueries({
queries: userIds.map(id => ({
queryKey: ['user-posts', id],
queryFn: () => fetchUserPosts(id),
staleTime: 1000 * 60 * 5,
})),
});
return <div>{/* render */}</div>;
}
```
## Memory Management
### Limit Cache Size
```tsx
const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 60 * 5, // 5 minutes
},
},
});
// Manually clear old queries
queryClient.clear(); // Clear all cache
// Remove specific queries
queryClient.removeQueries({ queryKey: ['old-data'] });
```
### Remove Queries on Unmount
```tsx
function ExpensiveComponent() {
const { data } = useQuery({
queryKey: ['expensive-data'],
queryFn: fetchExpensiveData,
gcTime: 0, // Remove immediately when component unmounts
});
return <div>{/* render */}</div>;
}
```
## Network Optimization
### Batch Requests
If your API supports batching:
```tsx
// Collect query keys and batch them
const batchedQueryFn = async (keys) => {
const ids = keys.map(key => key[1]);
const results = await fetch(`/api/items?ids=${ids.join(',')}`);
return results.json();
};
// Use in queries
useQuery({
queryKey: ['item', itemId],
queryFn: () => batchedQueryFn([['item', itemId]]),
});
```
### Request Cancellation
```tsx
useQuery({
queryKey: ['search', searchTerm],
queryFn: async ({ signal }) => {
// AbortSignal automatically provided
const res = await fetch(`/api/search?q=${searchTerm}`, { signal });
return res.json();
},
});
// When searchTerm changes, previous request is cancelled
```
### Retry Configuration
```tsx
// ❌ Retry immediately 3 times
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
retry: 3,
});
// ✅ Exponential backoff
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
retry: 3,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
});
```
## Dependent Queries
Avoid waterfalls by enabling queries in parallel when possible:
```tsx
// ❌ Waterfall - queries run sequentially
function UserDashboard({ userId }) {
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
const { data: posts } = useQuery({
queryKey: ['posts', user?.id],
queryFn: () => fetchPosts(user.id),
enabled: !!user?.id, // Waits for user
});
const { data: comments } = useQuery({
queryKey: ['comments', user?.id],
queryFn: () => fetchComments(user.id),
enabled: !!user?.id, // Also waits for user
});
}
// ✅ Optimized - posts and comments fetch in parallel after user loads
```
## Code Splitting
### Lazy Load Query Client
```tsx
import { lazy, Suspense } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const ReactQueryDevtools = lazy(() =>
import('@tanstack/react-query-devtools').then(mod => ({
default: mod.ReactQueryDevtools,
}))
);
function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
<Suspense fallback={null}>
<ReactQueryDevtools />
</Suspense>
</QueryClientProvider>
);
}
```
## Monitoring Performance
### Using DevTools
```tsx
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
<ReactQueryDevtools
initialIsOpen={false}
position="bottom-right"
/>
</QueryClientProvider>
);
}
```
### Custom Logger
```tsx
const queryClient = new QueryClient({
logger: {
log: (...args) => console.log(...args),
warn: (...args) => console.warn(...args),
error: (...args) => console.error(...args),
},
defaultOptions: {
queries: {
onSuccess: (data, query) => {
console.log(`Query ${query.queryKey} succeeded`, data);
},
onError: (error, query) => {
console.error(`Query ${query.queryKey} failed`, error);
},
},
},
});
```
### Performance Metrics
```tsx
useQuery({
queryKey: ['todos'],
queryFn: async () => {
const start = performance.now();
const data = await fetchTodos();
const duration = performance.now() - start;
console.log(`Query took ${duration}ms`);
return data;
},
});
```
## Best Practices
1. **Set Appropriate staleTime**
```tsx
// Static data
staleTime: Infinity
// Frequently changing
staleTime: 0
// Moderate
staleTime: 1000 * 60 * 5 // 5 minutes
```
2. **Use Prefetching**
- Hover intent
- Route prediction
- Next page in pagination
3. **Optimize with select**
```tsx
select: (data) => data.filter(/* ... */)
```
4. **Disable Unnecessary Refetching**
```tsx
refetchOnWindowFocus: false
refetchOnReconnect: false
```
5. **Use Structural Sharing**
- Enabled by default
- Disable for very large datasets
6. **Implement Virtual Scrolling**
- For long lists
- For infinite queries
7. **Monitor with DevTools**
- Watch for unnecessary refetches
- Check cache effectiveness
- Identify slow queries
8. **Batch Parallel Queries**
- Use useQueries
- Reduce waterfalls
9. **Clean Up Unused Cache**
```tsx
gcTime: 1000 * 60 * 5
```
10. **Use Request Cancellation**
- Automatically handled by TanStack Query
- Ensures old requests don't override new ones

View File

@@ -0,0 +1,649 @@
# Query Invalidation
Query invalidation is the process of marking queries as stale and potentially refetching them. This is essential for keeping your cache in sync with server state after mutations.
## Basic Invalidation
```tsx
import { useMutation, useQueryClient } from '@tanstack/react-query';
function CreateTodo() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (newTodo) => {
return fetch('/api/todos', {
method: 'POST',
body: JSON.stringify(newTodo),
}).then(res => res.json());
},
onSuccess: () => {
// Mark todos queries as stale and refetch
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
return (
<button onClick={() => mutation.mutate({ title: 'New Todo' })}>
Create Todo
</button>
);
}
```
## Invalidation Methods
### invalidateQueries
Marks queries as stale and triggers refetch of active queries:
```tsx
// Invalidate all queries
queryClient.invalidateQueries();
// Invalidate specific query
queryClient.invalidateQueries({ queryKey: ['todos'] });
// Invalidate query with exact match
queryClient.invalidateQueries({ queryKey: ['todo', todoId], exact: true });
// Invalidate and wait for refetch
await queryClient.invalidateQueries({ queryKey: ['todos'] });
```
### refetchQueries
Directly refetch queries without marking as stale first:
```tsx
// Refetch all queries
queryClient.refetchQueries();
// Refetch specific queries
queryClient.refetchQueries({ queryKey: ['todos'] });
// Refetch only active queries
queryClient.refetchQueries({ queryKey: ['todos'], type: 'active' });
// Refetch only inactive queries
queryClient.refetchQueries({ queryKey: ['todos'], type: 'inactive' });
```
### resetQueries
Reset queries to their initial state:
```tsx
// Reset and refetch
queryClient.resetQueries({ queryKey: ['todos'] });
// Reset specific query
queryClient.resetQueries({ queryKey: ['todo', todoId] });
```
## Query Key Matching
### Prefix Matching
By default, invalidateQueries uses prefix matching:
```tsx
// This query
useQuery({ queryKey: ['todos', 'list', { page: 1 }], queryFn: fetchTodos });
// Is invalidated by any of these:
queryClient.invalidateQueries({ queryKey: ['todos'] });
queryClient.invalidateQueries({ queryKey: ['todos', 'list'] });
queryClient.invalidateQueries({ queryKey: ['todos', 'list', { page: 1 }] });
// But NOT by these:
queryClient.invalidateQueries({ queryKey: ['todos', 'detail'] });
queryClient.invalidateQueries({ queryKey: ['users'] });
```
### Exact Matching
Use `exact: true` for precise matching:
```tsx
// Only invalidate this exact query key
queryClient.invalidateQueries({
queryKey: ['todos', 'list', { page: 1 }],
exact: true,
});
// This would invalidate:
useQuery({ queryKey: ['todos', 'list', { page: 1 }], ... });
// But NOT these:
useQuery({ queryKey: ['todos', 'list', { page: 2 }], ... });
useQuery({ queryKey: ['todos', 'list'], ... });
useQuery({ queryKey: ['todos'], ... });
```
### Predicate Functions
Use custom matching logic:
```tsx
// Invalidate all todos queries except detail queries
queryClient.invalidateQueries({
predicate: (query) => {
return query.queryKey[0] === 'todos' && query.queryKey[1] !== 'detail';
},
});
// Invalidate stale queries only
queryClient.invalidateQueries({
predicate: (query) => {
return query.state.isInvalidated;
},
});
// Invalidate based on query data
queryClient.invalidateQueries({
predicate: (query) => {
const data = query.state.data as Todo[] | undefined;
return data?.some((todo) => todo.userId === targetUserId) ?? false;
},
});
```
## Invalidation Timing
### Immediate Invalidation
Invalidate and refetch immediately:
```tsx
const mutation = useMutation({
mutationFn: createTodo,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
```
### Delayed Invalidation
Wait for mutation to settle:
```tsx
const mutation = useMutation({
mutationFn: createTodo,
onSettled: () => {
// Runs after success or error
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
```
### Conditional Invalidation
Only invalidate under certain conditions:
```tsx
const mutation = useMutation({
mutationFn: updateTodo,
onSuccess: (data, variables) => {
if (data.isPublished) {
// Only invalidate if todo was published
queryClient.invalidateQueries({ queryKey: ['todos', 'published'] });
}
},
});
```
## Refetch Strategies
### Refetch Active Queries Only
```tsx
queryClient.invalidateQueries({
queryKey: ['todos'],
refetchType: 'active', // Only refetch active queries (default)
});
```
### Refetch All Queries
```tsx
queryClient.invalidateQueries({
queryKey: ['todos'],
refetchType: 'all', // Refetch both active and inactive queries
});
```
### Don't Refetch
```tsx
queryClient.invalidateQueries({
queryKey: ['todos'],
refetchType: 'none', // Only mark as stale, don't refetch
});
```
## Invalidation Patterns
### After Create
```tsx
const createTodo = useMutation({
mutationFn: (newTodo) => fetch('/api/todos', { method: 'POST', ... }),
onSuccess: () => {
// Invalidate list queries to show new item
queryClient.invalidateQueries({ queryKey: ['todos', 'list'] });
},
});
```
### After Update
```tsx
const updateTodo = useMutation({
mutationFn: ({ id, updates }) => fetch(`/api/todos/${id}`, { method: 'PATCH', ... }),
onSuccess: (data, { id }) => {
// Invalidate specific item
queryClient.invalidateQueries({ queryKey: ['todo', id] });
// Invalidate list in case item moved categories, etc.
queryClient.invalidateQueries({ queryKey: ['todos', 'list'] });
},
});
```
### After Delete
```tsx
const deleteTodo = useMutation({
mutationFn: (id) => fetch(`/api/todos/${id}`, { method: 'DELETE' }),
onSuccess: (_, id) => {
// Remove specific item from cache
queryClient.removeQueries({ queryKey: ['todo', id] });
// Invalidate lists
queryClient.invalidateQueries({ queryKey: ['todos', 'list'] });
},
});
```
### Bulk Operations
```tsx
const markAllDone = useMutation({
mutationFn: () => fetch('/api/todos/mark-all-done', { method: 'POST' }),
onSuccess: () => {
// Invalidate all todo-related queries
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
```
## Related Query Invalidation
### Update Multiple Related Queries
```tsx
const updateUser = useMutation({
mutationFn: ({ userId, updates }) => updateUserApi(userId, updates),
onSuccess: (data, { userId }) => {
// Invalidate user detail
queryClient.invalidateQueries({ queryKey: ['user', userId] });
// Invalidate user list
queryClient.invalidateQueries({ queryKey: ['users'] });
// Invalidate user's posts
queryClient.invalidateQueries({ queryKey: ['posts', 'user', userId] });
// Invalidate user's comments
queryClient.invalidateQueries({ queryKey: ['comments', 'user', userId] });
},
});
```
### Hierarchical Invalidation
```tsx
// Query key structure:
// ['todos'] - all todos
// ['todos', 'list'] - todo lists
// ['todos', 'list', filters] - filtered lists
// ['todos', 'detail'] - todo details
// ['todos', 'detail', id] - specific todo
const updateTodo = useMutation({
mutationFn: updateTodoApi,
onSuccess: (data, { id }) => {
// Invalidate specific todo detail
queryClient.invalidateQueries({ queryKey: ['todos', 'detail', id] });
// Invalidate all list queries (they might show this todo)
queryClient.invalidateQueries({ queryKey: ['todos', 'list'] });
},
});
```
## Invalidation with Infinite Queries
### Invalidate All Pages
```tsx
const createPost = useMutation({
mutationFn: (newPost) => createPostApi(newPost),
onSuccess: () => {
// Refetches all pages
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
```
### Invalidate Specific Pages
```tsx
queryClient.invalidateQueries({
queryKey: ['posts'],
refetchPage: (page, index) => {
// Only refetch first page
return index === 0;
},
});
```
### Selective Page Refetch
```tsx
const updatePost = useMutation({
mutationFn: ({ id, updates }) => updatePostApi(id, updates),
onSuccess: (data, { id }) => {
queryClient.invalidateQueries({
queryKey: ['posts'],
refetchPage: (page, index) => {
// Only refetch pages containing this post
return page.posts.some((post) => post.id === id);
},
});
},
});
```
## Advanced Invalidation
### Cascading Invalidation
```tsx
const deleteProject = useMutation({
mutationFn: (projectId) => deleteProjectApi(projectId),
onSuccess: async (_, projectId) => {
// Step 1: Remove project from cache
queryClient.removeQueries({ queryKey: ['project', projectId] });
// Step 2: Invalidate project list
await queryClient.invalidateQueries({ queryKey: ['projects'] });
// Step 3: Invalidate related resources
await queryClient.invalidateQueries({ queryKey: ['tasks', 'project', projectId] });
await queryClient.invalidateQueries({ queryKey: ['members', 'project', projectId] });
// Step 4: Invalidate summary/stats
await queryClient.invalidateQueries({ queryKey: ['stats'] });
},
});
```
### Debounced Invalidation
For frequent updates, debounce invalidation:
```tsx
import { useDebouncedCallback } from 'use-debounce';
function SearchableList() {
const queryClient = useQueryClient();
const debouncedInvalidate = useDebouncedCallback(() => {
queryClient.invalidateQueries({ queryKey: ['search-results'] });
}, 500);
const updateFilters = (newFilters) => {
setFilters(newFilters);
debouncedInvalidate();
};
return <FilterPanel onChange={updateFilters} />;
}
```
### Throttled Invalidation
```tsx
import { throttle } from 'lodash';
const throttledInvalidate = throttle(() => {
queryClient.invalidateQueries({ queryKey: ['live-data'] });
}, 1000);
// In a websocket listener
socket.on('update', () => {
throttledInvalidate();
});
```
## Query Filters
Use query filters for more complex matching:
```tsx
import { QueryFilters } from '@tanstack/react-query';
const filters: QueryFilters = {
queryKey: ['todos'],
type: 'active', // 'active' | 'inactive' | 'all'
stale: true, // Only stale queries
exact: false, // Prefix matching
predicate: (query) => {
// Custom logic
return query.state.dataUpdatedAt > Date.now() - 60000;
},
};
queryClient.invalidateQueries(filters);
```
### Filter by State
```tsx
// Only invalidate stale queries
queryClient.invalidateQueries({
queryKey: ['todos'],
stale: true,
});
// Only invalidate fetching queries
queryClient.invalidateQueries({
predicate: (query) => query.state.fetchStatus === 'fetching',
});
```
### Filter by Type
```tsx
// Only active queries (currently mounted)
queryClient.invalidateQueries({
queryKey: ['todos'],
type: 'active',
});
// Only inactive queries (not mounted)
queryClient.invalidateQueries({
queryKey: ['todos'],
type: 'inactive',
});
// All queries
queryClient.invalidateQueries({
queryKey: ['todos'],
type: 'all',
});
```
## Performance Considerations
### Batch Invalidations
```tsx
// ❌ Multiple separate invalidations
queryClient.invalidateQueries({ queryKey: ['todos'] });
queryClient.invalidateQueries({ queryKey: ['users'] });
queryClient.invalidateQueries({ queryKey: ['projects'] });
// ✅ Batch with predicate
queryClient.invalidateQueries({
predicate: (query) => {
const key = query.queryKey[0];
return key === 'todos' || key === 'users' || key === 'projects';
},
});
```
### Smart Invalidation
Only invalidate what's needed:
```tsx
const updateTodo = useMutation({
mutationFn: updateTodoApi,
onSuccess: (data, { id, updates }) => {
// If only title changed, no need to invalidate lists
if (Object.keys(updates).length === 1 && 'title' in updates) {
queryClient.invalidateQueries({ queryKey: ['todo', id], exact: true });
} else {
// Status/category changed, invalidate lists too
queryClient.invalidateQueries({ queryKey: ['todo', id] });
queryClient.invalidateQueries({ queryKey: ['todos', 'list'] });
}
},
});
```
### Prevent Over-Invalidation
```tsx
// ❌ Too broad - invalidates everything
queryClient.invalidateQueries();
// ❌ Still too broad - invalidates all todo queries
queryClient.invalidateQueries({ queryKey: ['todos'] });
// ✅ Specific - only invalidates affected queries
queryClient.invalidateQueries({ queryKey: ['todos', 'list', filters] });
queryClient.invalidateQueries({ queryKey: ['todo', todoId] });
```
## Alternatives to Invalidation
Sometimes you don't need invalidation:
### Direct Cache Update
```tsx
const toggleTodo = useMutation({
mutationFn: (todoId) => toggleTodoApi(todoId),
onSuccess: (data, todoId) => {
// Directly update cache instead of invalidating
queryClient.setQueryData(['todo', todoId], data);
queryClient.setQueryData(['todos'], (old) =>
old?.map((todo) => (todo.id === todoId ? data : todo))
);
},
});
```
### Optimistic Updates
```tsx
const updateTodo = useMutation({
mutationFn: updateTodoApi,
onMutate: async ({ id, updates }) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previous = queryClient.getQueryData(['todos']);
// Update cache optimistically
queryClient.setQueryData(['todos'], (old) =>
old?.map((todo) => (todo.id === id ? { ...todo, ...updates } : todo))
);
return { previous };
},
onError: (err, vars, context) => {
queryClient.setQueryData(['todos'], context.previous);
},
// No need for invalidation if optimistic update is accurate
});
```
### Polling
```tsx
// Instead of manual invalidation, use automatic refetching
useQuery({
queryKey: ['live-data'],
queryFn: fetchLiveData,
refetchInterval: 5000, // Auto-refetch every 5 seconds
});
```
## Best Practices
1. **Be Specific with Query Keys**
```tsx
// ✅ Good - specific invalidation
queryClient.invalidateQueries({ queryKey: ['todos', 'list', filters] });
// ❌ Bad - too broad
queryClient.invalidateQueries({ queryKey: ['todos'] });
```
2. **Use Exact Matching When Appropriate**
```tsx
queryClient.invalidateQueries({
queryKey: ['todo', todoId],
exact: true, // Only this specific todo
});
```
3. **Invalidate in onSuccess for Success-Only**
```tsx
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
}
```
4. **Invalidate in onSettled for Always**
```tsx
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
}
```
5. **Consider Alternatives**
- Direct cache updates for simple changes
- Optimistic updates for better UX
- Polling for real-time data
6. **Batch Related Invalidations**
```tsx
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['todos'] }),
queryClient.invalidateQueries({ queryKey: ['stats'] }),
]);
```
7. **Use Predicate Functions for Complex Logic**
```tsx
queryClient.invalidateQueries({
predicate: (query) => {
// Custom matching logic
return shouldInvalidate(query);
},
});
```
8. **Monitor Invalidation Performance**
- Use React Query DevTools
- Check for unnecessary refetches
- Optimize query key structure

View File

@@ -0,0 +1,813 @@
# TypeScript Guide
TanStack Query v5 is written in TypeScript and provides excellent type safety and inference out of the box.
## Basic Type Inference
TanStack Query infers types from your query function return values:
```tsx
import { useQuery } from '@tanstack/react-query';
interface Todo {
id: number;
title: string;
done: boolean;
}
function useTodos() {
return useQuery({
queryKey: ['todos'],
queryFn: async (): Promise<Todo[]> => {
const res = await fetch('/api/todos');
return res.json();
},
});
}
function TodoList() {
const { data } = useTodos();
// data is automatically typed as Todo[] | undefined
return (
<ul>
{data?.map((todo) => (
// todo is typed as Todo
<li key={todo.id}>{todo.title}</li>
))}
</ul>
);
}
```
## Typing Query Functions
### Inline Query Functions
```tsx
const { data } = useQuery({
queryKey: ['todo', todoId],
queryFn: async (): Promise<Todo> => {
const res = await fetch(`/api/todos/${todoId}`);
if (!res.ok) throw new Error('Failed to fetch');
return res.json();
},
});
// data is typed as Todo | undefined
```
### Extracted Query Functions
```tsx
async function fetchTodo(id: number): Promise<Todo> {
const res = await fetch(`/api/todos/${id}`);
if (!res.ok) throw new Error('Failed to fetch');
return res.json();
}
const { data } = useQuery({
queryKey: ['todo', todoId],
queryFn: () => fetchTodo(todoId),
});
// data is automatically typed as Todo | undefined
```
### Query Functions with QueryKey
Access the query key in your function with proper typing:
```tsx
import { QueryFunction } from '@tanstack/react-query';
const fetchTodo: QueryFunction<Todo, ['todo', number]> = async ({ queryKey }) => {
const [_, id] = queryKey;
// id is typed as number
const res = await fetch(`/api/todos/${id}`);
return res.json();
};
const { data } = useQuery({
queryKey: ['todo', todoId],
queryFn: fetchTodo,
});
```
## Typing Mutations
### Basic Mutation Types
```tsx
import { useMutation } from '@tanstack/react-query';
interface CreateTodoInput {
title: string;
done?: boolean;
}
interface CreateTodoResponse {
id: number;
title: string;
done: boolean;
createdAt: string;
}
const mutation = useMutation({
mutationFn: async (input: CreateTodoInput): Promise<CreateTodoResponse> => {
const res = await fetch('/api/todos', {
method: 'POST',
body: JSON.stringify(input),
});
return res.json();
},
});
// TypeScript knows:
// - mutation.mutate expects CreateTodoInput
// - mutation.data is CreateTodoResponse | undefined
mutation.mutate({ title: 'New Todo' });
```
### Generic Mutation Type
```tsx
import { UseMutationResult } from '@tanstack/react-query';
type CreateTodoMutation = UseMutationResult<
CreateTodoResponse, // TData - successful response
Error, // TError - error type
CreateTodoInput, // TVariables - mutation input
unknown // TContext - context from onMutate
>;
function useCreateTodo(): CreateTodoMutation {
return useMutation({
mutationFn: async (input: CreateTodoInput): Promise<CreateTodoResponse> => {
const res = await fetch('/api/todos', {
method: 'POST',
body: JSON.stringify(input),
});
return res.json();
},
});
}
```
## Error Typing
### Typed Errors
```tsx
interface ApiError {
message: string;
code: string;
details?: Record<string, string>;
}
const { data, error } = useQuery<Todo[], ApiError>({
queryKey: ['todos'],
queryFn: async () => {
const res = await fetch('/api/todos');
if (!res.ok) {
const errorData: ApiError = await res.json();
throw errorData;
}
return res.json();
},
});
if (error) {
// error is typed as ApiError
console.log(error.message);
console.log(error.code);
}
```
### Error Type Narrowing
```tsx
function TodoList() {
const { data, error, isError } = useQuery<Todo[], ApiError>({
queryKey: ['todos'],
queryFn: fetchTodos,
});
if (isError) {
// TypeScript knows error is ApiError here
return <div>Error: {error.message} (Code: {error.code})</div>;
}
// TypeScript knows data is Todo[] | undefined here
return <div>{data?.map(todo => <TodoItem key={todo.id} todo={todo} />)}</div>;
}
```
## Generic Type Parameters
### useQuery Generics
```tsx
useQuery<
TData, // Type of data returned (inferred from queryFn)
TError, // Type of errors (default: Error)
TQueryKey // Type of query key (inferred)
>({ /* ... */ });
```
Example with all generics:
```tsx
interface User {
id: number;
name: string;
}
interface UserError {
message: string;
statusCode: number;
}
const { data, error } = useQuery<User, UserError, ['user', number]>({
queryKey: ['user', userId],
queryFn: async ({ queryKey }): Promise<User> => {
const [_, id] = queryKey;
const res = await fetch(`/api/users/${id}`);
if (!res.ok) {
throw { message: 'Failed to fetch', statusCode: res.status };
}
return res.json();
},
});
```
### useMutation Generics
```tsx
useMutation<
TData, // Type of successful response
TError, // Type of error
TVariables, // Type of mutation variables
TContext // Type of context from onMutate
>({ /* ... */ });
```
Example:
```tsx
interface UpdateTodoInput {
id: number;
title?: string;
done?: boolean;
}
interface UpdateTodoResponse {
id: number;
title: string;
done: boolean;
updatedAt: string;
}
interface UpdateTodoContext {
previousTodos: Todo[];
}
const mutation = useMutation<
UpdateTodoResponse,
ApiError,
UpdateTodoInput,
UpdateTodoContext
>({
mutationFn: async (input) => {
const res = await fetch(`/api/todos/${input.id}`, {
method: 'PATCH',
body: JSON.stringify(input),
});
return res.json();
},
onMutate: async (variables) => {
const previousTodos = queryClient.getQueryData<Todo[]>(['todos']) ?? [];
// Must return UpdateTodoContext
return { previousTodos };
},
onError: (error, variables, context) => {
// error: ApiError
// variables: UpdateTodoInput
// context: UpdateTodoContext | undefined
if (context) {
queryClient.setQueryData(['todos'], context.previousTodos);
}
},
});
```
## Typing QueryClient Methods
### setQueryData
```tsx
const queryClient = useQueryClient();
// Type-safe setQueryData
queryClient.setQueryData<Todo[]>(['todos'], (old) => {
// old is typed as Todo[] | undefined
return old ? [...old, newTodo] : [newTodo];
});
```
### getQueryData
```tsx
const todos = queryClient.getQueryData<Todo[]>(['todos']);
// todos is typed as Todo[] | undefined
if (todos) {
// TypeScript knows todos is Todo[] here
console.log(todos.length);
}
```
### invalidateQueries
```tsx
// Type-safe query key
queryClient.invalidateQueries({ queryKey: ['todos'] });
queryClient.invalidateQueries({ queryKey: ['todo', todoId] });
```
## Typing Infinite Queries
### Basic Infinite Query
```tsx
interface PostsPage {
posts: Post[];
nextCursor: number | null;
}
const { data } = useInfiniteQuery<PostsPage>({
queryKey: ['posts'],
queryFn: async ({ pageParam = 0 }): Promise<PostsPage> => {
const res = await fetch(`/api/posts?cursor=${pageParam}`);
return res.json();
},
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
});
// data.pages is typed as PostsPage[]
data?.pages.forEach((page) => {
// page is typed as PostsPage
page.posts.forEach((post) => {
// post is typed as Post
console.log(post.title);
});
});
```
### Infinite Query with Generics
```tsx
useInfiniteQuery<
TData, // Type of page data
TError, // Type of error
TQueryData, // Type of transformed data (from select)
TQueryKey, // Type of query key
TPageParam // Type of page parameter
>({ /* ... */ });
```
Example:
```tsx
const { data } = useInfiniteQuery<
PostsPage,
ApiError,
PostsPage,
['posts', string],
number
>({
queryKey: ['posts', filter],
queryFn: async ({ pageParam }): Promise<PostsPage> => {
const res = await fetch(`/api/posts?cursor=${pageParam}&filter=${filter}`);
return res.json();
},
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
});
```
## Typing Select Transformations
### Transform Query Data
```tsx
interface TodoApiResponse {
id: number;
title: string;
completed: boolean;
}
interface Todo {
id: number;
title: string;
done: boolean; // renamed from completed
}
const { data } = useQuery({
queryKey: ['todos'],
queryFn: async (): Promise<TodoApiResponse[]> => {
const res = await fetch('/api/todos');
return res.json();
},
select: (data): Todo[] => {
// data is typed as TodoApiResponse[]
return data.map(todo => ({
id: todo.id,
title: todo.title,
done: todo.completed, // transform
}));
},
});
// data is now typed as Todo[] | undefined
```
### Partial Selection
```tsx
interface User {
id: number;
name: string;
email: string;
role: string;
metadata: Record<string, unknown>;
}
const { data } = useQuery({
queryKey: ['user', userId],
queryFn: async (): Promise<User> => {
const res = await fetch(`/api/users/${userId}`);
return res.json();
},
select: (user) => ({
id: user.id,
name: user.name,
}),
});
// data is typed as { id: number; name: string } | undefined
```
## Custom Hooks with Types
### Reusable Typed Hooks
```tsx
interface UseTodoOptions {
refetchInterval?: number;
enabled?: boolean;
}
function useTodo(id: number, options?: UseTodoOptions) {
return useQuery<Todo, ApiError>({
queryKey: ['todo', id],
queryFn: async (): Promise<Todo> => {
const res = await fetch(`/api/todos/${id}`);
if (!res.ok) {
const error: ApiError = await res.json();
throw error;
}
return res.json();
},
refetchInterval: options?.refetchInterval,
enabled: options?.enabled,
});
}
// Usage with full type safety
const { data, error, isLoading } = useTodo(1, { refetchInterval: 5000 });
```
### Generic Custom Hooks
```tsx
function useResource<T>(resourceType: string, id: number) {
return useQuery<T, ApiError>({
queryKey: [resourceType, id],
queryFn: async (): Promise<T> => {
const res = await fetch(`/api/${resourceType}/${id}`);
if (!res.ok) throw await res.json();
return res.json();
},
});
}
// Usage
const { data: user } = useResource<User>('users', 1);
// data is typed as User | undefined
const { data: post } = useResource<Post>('posts', 123);
// data is typed as Post | undefined
```
## Typing Query Keys
### Const Query Keys
```tsx
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,
};
// Type-safe query keys
useQuery({
queryKey: todoKeys.detail(todoId),
queryFn: () => fetchTodo(todoId),
});
// Type-safe invalidation
queryClient.invalidateQueries({ queryKey: todoKeys.all });
queryClient.invalidateQueries({ queryKey: todoKeys.detail(todoId) });
```
### QueryKey Type Helper
```tsx
import { QueryKey } from '@tanstack/react-query';
type TodoQueryKey = ['todos'] | ['todos', 'list', string] | ['todos', 'detail', number];
function useTodoQuery(key: TodoQueryKey) {
return useQuery({
queryKey: key,
queryFn: async () => {
// Implementation based on key
},
});
}
```
## Typing Mutation Context
### Context with Optimistic Updates
```tsx
interface UpdateTodoVariables {
id: number;
updates: Partial<Todo>;
}
interface UpdateTodoContext {
previousTodos: Todo[];
previousTodo: Todo;
rollback: () => void;
}
const mutation = useMutation<
Todo,
ApiError,
UpdateTodoVariables,
UpdateTodoContext
>({
mutationFn: async ({ id, updates }) => {
const res = await fetch(`/api/todos/${id}`, {
method: 'PATCH',
body: JSON.stringify(updates),
});
return res.json();
},
onMutate: async ({ id, updates }): Promise<UpdateTodoContext> => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previousTodos = queryClient.getQueryData<Todo[]>(['todos']) ?? [];
const previousTodo = queryClient.getQueryData<Todo>(['todo', id])!;
// Optimistic update
queryClient.setQueryData<Todo[]>(['todos'], (old) =>
old?.map((todo) => (todo.id === id ? { ...todo, ...updates } : todo))
);
const rollback = () => {
queryClient.setQueryData(['todos'], previousTodos);
queryClient.setQueryData(['todo', id], previousTodo);
};
return { previousTodos, previousTodo, rollback };
},
onError: (_error, _variables, context) => {
// context is typed as UpdateTodoContext | undefined
context?.rollback();
},
});
```
## Type-Safe Query Options
### Shared Query Options
```tsx
import { UseQueryOptions } from '@tanstack/react-query';
type TodoQueryOptions = UseQueryOptions<Todo, ApiError, Todo, ['todo', number]>;
const defaultTodoOptions: Partial<TodoQueryOptions> = {
staleTime: 1000 * 60 * 5,
retry: 3,
};
function useTodo(id: number, options?: Partial<TodoQueryOptions>) {
return useQuery<Todo, ApiError>({
queryKey: ['todo', id],
queryFn: () => fetchTodo(id),
...defaultTodoOptions,
...options,
});
}
```
## Strict Type Safety
### Enable Strict Mode
```tsx
// tsconfig.json
{
"compilerOptions": {
"strict": true,
"strictNullChecks": true,
"noImplicitAny": true
}
}
```
### Avoid Type Assertions
```tsx
// ❌ Bad - using type assertion
const data = queryClient.getQueryData(['todos']) as Todo[];
// ✅ Good - proper type checking
const data = queryClient.getQueryData<Todo[]>(['todos']);
if (data) {
// TypeScript knows data is Todo[] here
console.log(data.length);
}
```
### Non-Null Assertions
Use sparingly and only when you're certain:
```tsx
// ❌ Risky
const todos = queryClient.getQueryData<Todo[]>(['todos'])!;
// ✅ Better
const todos = queryClient.getQueryData<Todo[]>(['todos']);
if (!todos) {
throw new Error('Todos not found in cache');
}
// Now safe to use todos
```
## Typing DevTools
```tsx
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
{/* TypeScript will check props */}
<ReactQueryDevtools
initialIsOpen={false}
buttonPosition="bottom-right"
/>
</QueryClientProvider>
);
}
```
## Common Type Issues
### Issue: Cannot infer type from async function
```tsx
// ❌ Problem
const { data } = useQuery({
queryKey: ['todos'],
queryFn: async () => {
const res = await fetch('/api/todos');
return res.json(); // Returns any
},
});
// ✅ Solution 1: Add return type to queryFn
const { data } = useQuery({
queryKey: ['todos'],
queryFn: async (): Promise<Todo[]> => {
const res = await fetch('/api/todos');
return res.json();
},
});
// ✅ Solution 2: Use generic parameter
const { data } = useQuery<Todo[]>({
queryKey: ['todos'],
queryFn: async () => {
const res = await fetch('/api/todos');
return res.json();
},
});
```
### Issue: Context type mismatch
```tsx
// ❌ Problem - context type doesn't match
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: () => {
return { previous: [] }; // Returns wrong type
},
onError: (err, vars, context) => {
context.previousTodos; // Error: previousTodos doesn't exist
},
});
// ✅ Solution - Define context interface
interface MutationContext {
previousTodos: Todo[];
}
const mutation = useMutation<Todo, Error, UpdateInput, MutationContext>({
mutationFn: updateTodo,
onMutate: async (): Promise<MutationContext> => {
const previousTodos = queryClient.getQueryData<Todo[]>(['todos']) ?? [];
return { previousTodos };
},
onError: (err, vars, context) => {
if (context) {
context.previousTodos; // ✅ Properly typed
}
},
});
```
## Best Practices
1. **Always type your query functions**
```tsx
queryFn: async (): Promise<Todo[]> => { /* ... */ }
```
2. **Use type inference when possible**
```tsx
// Let TanStack Query infer types from queryFn return type
const { data } = useQuery({
queryKey: ['todos'],
queryFn: async (): Promise<Todo[]> => fetchTodos(),
});
// data is automatically Todo[] | undefined
```
3. **Define error types**
```tsx
const { error } = useQuery<Todo[], ApiError>({ /* ... */ });
```
4. **Use const assertions for query keys**
```tsx
const todoKeys = {
all: ['todos'] as const,
detail: (id: number) => ['todos', id] as const,
};
```
5. **Create reusable typed hooks**
```tsx
function useTodo(id: number) {
return useQuery<Todo, ApiError>({ /* ... */ });
}
```
6. **Type mutation context for optimistic updates**
```tsx
useMutation<TData, TError, TVariables, TContext>({ /* ... */ })
```
7. **Use strict TypeScript settings**
```json
{
"strict": true,
"strictNullChecks": true
}
```
8. **Avoid type assertions**
- Use type parameters instead
- Check for undefined/null before using data