654 lines
15 KiB
Markdown
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
|