Initial commit
This commit is contained in:
653
skills/skill/references/infinite-queries.md
Normal file
653
skills/skill/references/infinite-queries.md
Normal 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
|
||||
616
skills/skill/references/optimistic-updates.md
Normal file
616
skills/skill/references/optimistic-updates.md
Normal 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
|
||||
653
skills/skill/references/performance.md
Normal file
653
skills/skill/references/performance.md
Normal 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
|
||||
649
skills/skill/references/query-invalidation.md
Normal file
649
skills/skill/references/query-invalidation.md
Normal 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
|
||||
813
skills/skill/references/typescript.md
Normal file
813
skills/skill/references/typescript.md
Normal 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
|
||||
Reference in New Issue
Block a user