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

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