Files
gh-tenequm-claude-plugins-t…/skills/skill/references/infinite-queries.md
2025-11-30 09:01:30 +08:00

654 lines
15 KiB
Markdown

# 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