Infinite Queries in React: Pagination and Lazy Loading
useInfiniteQuery manages paginated and infinite-scroll data without manual page state. Instead of fetching page 1, then page 2, then manually merging them, the hook accumulates pages and provides methods to fetch the next page. This article covers implementing "Load More" buttons, infinite scrolling, and cursor-based pagination using a single declarative hook that handles caching, deduplication, and refetching across all pages.
I once spent two days debugging a "Load More" button that wasn't fetching page 3 because manual page state was out of sync with cache. Switching to useInfiniteQuery eliminated that entire class of bug: the hook manages pages, and I just call fetchNextPage(). The code got shorter and more correct at the same time.
What Is useInfiniteQuery and How It Differs from useQuery
useInfiniteQuery is a variant of useQuery designed for paginated and infinite-scroll data. Instead of storing a single data value, it stores an array of pages. When the user asks for more data, you call fetchNextPage(), and the hook appends the new page to the array without losing old data.
import { useInfiniteQuery } from '@tanstack/react-query';
function PostsList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: async ({ pageParam }) => {
const res = await fetch(`/api/posts?page=${pageParam}`);
return res.json();
},
initialPageParam: 1,
getNextPageParam: (lastPage) => {
// Return the next page number, or undefined if no more pages
return lastPage.nextPage;
},
});
return (
<div>
{data?.pages.map((page, i) => (
<div key={i}>
{page.items.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
))}
{hasNextPage && (
<button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading...' : 'Load More'}
</button>
)}
</div>
);
}
The key differences from useQuery:
data.pagesis an array of pages, not a single responsepageParamis passed to the fetcher to indicate which page to fetchinitialPageParamsets the first page identifier (usually 1 or a cursor)getNextPageParamcomputes the next page ID from the previous page's responsefetchNextPage()loads the next page and appends it to the cache
Implementing "Load More" Buttons
For explicit pagination ("Load More" button), use useInfiniteQuery with a page number:
function PostsList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: async ({ pageParam }) => {
const res = await fetch(`/api/posts?page=${pageParam}`);
if (!res.ok) throw new Error('Failed to fetch posts');
return res.json(); // { items: [...], hasMore: true }
},
initialPageParam: 1,
getNextPageParam: (lastPage, allPages) => {
// If the last page has fewer items than requested, there's no next page
if (lastPage.items.length < 10) return undefined;
// Otherwise, next page is current page + 1
return allPages.length + 1;
},
});
return (
<div>
{data?.pages.map((page) =>
page.items.map(post => (
<PostCard key={post.id} post={post} />
))
)}
{hasNextPage && (
<button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading...' : 'Load More Posts'}
</button>
)}
</div>
);
}
The getNextPageParam function receives the last page and all pages fetched so far. It returns the next pageParam value to pass to the fetcher, or undefined if there are no more pages.
Implementing Infinite Scroll
For infinite scroll (auto-load as user scrolls), combine useInfiniteQuery with an intersection observer:
function InfinitePostsList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: async ({ pageParam }) => {
const res = await fetch(`/api/posts?page=${pageParam}`);
return res.json();
},
initialPageParam: 1,
getNextPageParam: (lastPage, allPages) => {
return lastPage.items.length === 10 ? allPages.length + 1 : undefined;
},
});
const observerTarget = React.useRef(null);
React.useEffect(() => {
// Use Intersection Observer to detect when the sentinel element is visible
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
{ threshold: 0.1 }
);
if (observerTarget.current) {
observer.observe(observerTarget.current);
}
return () => observer.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
return (
<div>
{data?.pages.map((page) =>
page.items.map(post => (
<PostCard key={post.id} post={post} />
))
)}
{/* Sentinel element; when visible, load next page */}
<div ref={observerTarget} style={{ height: '100px' }}>
{isFetchingNextPage && <p>Loading more posts...</p>}
</div>
{!hasNextPage && data && <p>No more posts</p>}
</div>
);
}
When the sentinel element scrolls into view (detected by IntersectionObserver), the hook automatically fetches the next page. No manual scroll listeners needed.
Cursor-Based Pagination
For cursor-based pagination (common in real-time APIs), extract the cursor from each page:
function PostsWithCursor() {
const {
data,
fetchNextPage,
hasNextPage,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: async ({ pageParam }) => {
const params = pageParam ? `?cursor=${pageParam}` : '';
const res = await fetch(`/api/posts${params}`);
return res.json(); // { items: [...], nextCursor: 'abc123' }
},
initialPageParam: null, // Start with no cursor
getNextPageParam: (lastPage) => {
// Return the cursor for the next page
return lastPage.nextCursor;
},
});
return (
<div>
{data?.pages.map((page) =>
page.items.map(post => (
<PostCard key={post.id} post={post} />
))
)}
{hasNextPage && (
<button onClick={() => fetchNextPage()}>
Load More
</button>
)}
</div>
);
}
Cursor-based pagination is more stable than offset/limit because it handles insertions/deletions at the beginning of the list without gaps or duplicates.
Handling Page Fetching States
Distinguish between loading the first page and fetching subsequent pages:
function PostsList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: async ({ pageParam }) => {
const res = await fetch(`/api/posts?page=${pageParam}`);
return res.json();
},
initialPageParam: 1,
getNextPageParam: (lastPage, allPages) => {
return lastPage.items.length === 10 ? allPages.length + 1 : undefined;
},
});
// Show skeleton only on initial load
if (isLoading) {
return <PostSkeleton count={5} />;
}
return (
<div>
{data?.pages.map((page) =>
page.items.map(post => (
<PostCard key={post.id} post={post} />
))
)}
{isFetchingNextPage && (
<div style={{ textAlign: 'center', padding: '20px' }}>
Loading more posts...
</div>
)}
{hasNextPage && !isFetchingNextPage && (
<button onClick={() => fetchNextPage()}>
Load More
</button>
)}
</div>
);
}
Use isLoading for the initial page fetch; use isFetchingNextPage for subsequent pages.
Refetching All Pages After Mutations
When you add, edit, or delete posts, invalidate the entire query to refetch from page 1:
function useAddPost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (newPost) => {
const res = await fetch('/api/posts', {
method: 'POST',
body: JSON.stringify(newPost),
});
return res.json();
},
onSuccess: () => {
// Invalidate all pages so they refetch from page 1
queryClient.invalidateQueries({
queryKey: ['posts'],
exact: true,
});
},
});
}
function PostsList() {
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ['posts'],
queryFn: async ({ pageParam }) => {
const res = await fetch(`/api/posts?page=${pageParam}`);
return res.json();
},
initialPageParam: 1,
getNextPageParam: (lastPage) => {
return lastPage.items.length === 10 ? lastPage.nextPage : undefined;
},
});
const { mutate: addPost } = useAddPost();
return (
<div>
<button onClick={() => addPost({ title: 'New Post' })}>Add Post</button>
{data?.pages.map(page =>
page.items.map(post => <PostCard key={post.id} post={post} />)
)}
</div>
);
}
Invalidating the query resets all pages and refetches from initialPageParam.
Optimizing Page Caching with staleTime
Pages older than staleTime are refetched in the background on refetchOnMount or refetchOnWindowFocus:
const { data, fetchNextPage } = useInfiniteQuery({
queryKey: ['posts'],
queryFn: async ({ pageParam }) => {
const res = await fetch(`/api/posts?page=${pageParam}`);
return res.json();
},
initialPageParam: 1,
getNextPageParam: (lastPage) => {
return lastPage.items.length === 10 ? lastPage.nextPage : undefined;
},
staleTime: 1000 * 60 * 5, // Each page fresh for 5 minutes
gcTime: 1000 * 60 * 30, // Keep pages for 30 minutes
});
Each page is individually cached with its own staleness timer. Page 1 and page 3 can have different freshness states.
Real-World Infinite Comments Example
Here is a practical example: infinite comment list with load-more button:
function CommentsList({ postId }) {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
} = useInfiniteQuery({
queryKey: ['comments', postId],
queryFn: async ({ pageParam }) => {
const params = new URLSearchParams({
postId,
page: pageParam,
limit: 20,
});
const res = await fetch(`/api/comments?${params}`);
if (!res.ok) throw new Error('Failed to fetch comments');
return res.json(); // { comments: [...], hasMore: true }
},
initialPageParam: 1,
getNextPageParam: (lastPage, allPages) => {
return lastPage.hasMore ? allPages.length + 1 : undefined;
},
});
if (isLoading) return <CommentSkeleton count={3} />;
return (
<div>
{data?.pages.map((page) =>
page.comments.map(comment => (
<Comment key={comment.id} comment={comment} />
))
)}
{hasNextPage && (
<button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
style={{ marginTop: '20px' }}
>
{isFetchingNextPage ? 'Loading comments...' : 'Load More Comments'}
</button>
)}
{!hasNextPage && data && (
<p style={{ color: 'gray', textAlign: 'center' }}>No more comments</p>
)}
</div>
);
}
Key Takeaways
useInfiniteQueryaccumulates pages in adata.pagesarray; each page is cached and managed independently.initialPageParamsets the first page identifier;getNextPageParamcomputes the next identifier from the previous page's response.- Use
fetchNextPage()andhasNextPagefor explicit "Load More" buttons; combine withIntersectionObserverfor infinite scroll. - Cursor-based pagination is more stable than offset/limit for data that changes frequently.
- Distinguish between
isLoading(first page) andisFetchingNextPage(subsequent pages) for appropriate loading UI. - Invalidate the entire query after mutations to refetch from page 1 and ensure consistency.
Frequently Asked Questions
What happens if I call fetchNextPage() when hasNextPage is false?
Nothing. The hook prevents fetching when hasNextPage is false or isFetchingNextPage is true, so duplicate requests are prevented.
Can I use useInfiniteQuery with filters or sorting?
Yes. Include filters in the query key:
const { data, fetchNextPage } = useInfiniteQuery({
queryKey: ['posts', { sortBy: 'date', category: 'react' }],
queryFn: async ({ pageParam }) => {
const params = new URLSearchParams({ page: pageParam, sortBy: 'date', category: 'react' });
const res = await fetch(`/api/posts?${params}`);
return res.json();
},
initialPageParam: 1,
getNextPageParam: (lastPage) => lastPage.items.length === 10 ? lastPage.nextPage : undefined,
});
Changing the query key refetches from page 1 with the new filters.
What is the best approach for infinite scroll: IntersectionObserver or scroll listeners?
IntersectionObserver is preferred because it:
- Does not fire on every pixel of scroll (performance)
- Handles visibility correctly (even if the element is off-screen)
- Is a standard Web API with good browser support
Scroll event listeners are acceptable but less efficient and prone to flickering if not debounced.
Can I go to a specific page, or only forward?
useInfiniteQuery only supports forward pagination. To support arbitrary page jumping, use useQuery with a page state instead, or call queryClient.setQueryData() to manually set pages if you are certain of the data.
How do I handle deletions in an infinite list?
Option 1: Invalidate the query to refetch from page 1. Option 2: Use queryClient.setQueryData() to manually remove the item from the pages array. Option 3: Use optimistic updates in a mutation to remove the item locally while the delete request is in flight.