# 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
Loading...
; return ( <> {data.pages.map((page, i) => (
{page.posts.map((post) => (
{post.title}
))}
))} ); } ``` ## 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 // 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 }); ``` ## 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 (
{data?.pages.map((page, i) => (
{page.posts.map((post) => ( ))}
))} {hasNextPage && (
{isFetchingNextPage ? 'Loading more...' : 'Load more'}
)}
); } ``` ### 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 (
{data?.pages.map((page, i) => (
{page.posts.map((post) => ( ))}
))}
{isFetchingNextPage && 'Loading...'}
); } ``` ## 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
{data?.allPosts.map(post => )}
; ``` ### 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 (
{data?.pages.map((page, i) => (
{page.messages.map((message) => ( ))}
))}
); } ``` ## 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 (
{data?.pages.map((page, i) => (
{page.results.map((result) => ( ))}
))} {hasNextPage && ( )}
); } ``` ### 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 (
{data?.pages.map((page, i) => (
{page.items.map((item) => ( ))}
))} {hasNextPage && }
); } ``` ### 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
{/* render */}
; } ``` ## 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