Build Infinite Scroll with React Query's useInfiniteQuery
React Query's useInfiniteQuery hook abstracts infinite scroll logic: it manages multiple pages of data as a single cache entry, handles deduplication and merging, and provides fetchNextPage() and fetchPreviousPage() functions. Building infinite scroll with useInfiniteQuery eliminates 90% of boilerplate and edge cases.
The key insight is the pageParam: an opaque token (offset, cursor, or page number) that React Query tracks and passes to your fetch function. You update it to fetch the next page.
Understanding pageParam and getNextPageParam
Every page needs a unique identifier: the pageParam. For offset pagination, it's the offset value; for cursor pagination, it's the cursor string. React Query passes this to your queryFn and tracks it:
import { useInfiniteQuery } from '@tanstack/react-query';
async function fetchPosts(pageParam = 0) {
const offset = pageParam;
const response = await fetch(`/api/posts?offset=${offset}&limit=20`);
return response.json(); // { items: [...], total: 100 }
}
export function InfinitePostList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetching,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam = 0 }) => fetchPosts(pageParam),
getNextPageParam: (lastPage, allPages) => {
const totalFetched = allPages.reduce(
(sum, page) => sum + page.items.length,
0
);
return totalFetched `<` lastPage.total ? totalFetched : undefined;
},
});
return (
`<div>`
`<ul>`
{data?.pages.map((page) =>
page.items.map((post) => (
`<li key={post.id}>{post.title}`</li>`
))
)}
`</ul>`
`<button`
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
`>`
{isFetchingNextPage ? 'Loading...' : 'Load More'}
`</button>`
`</div>`
);
}
The getNextPageParam function is critical: it receives the last page fetched and the array of all pages, and returns the pageParam for the next request. Return undefined to signal there's no next page.
Infinite Scroll with Intersection Observer
To trigger fetches automatically, combine useInfiniteQuery with IntersectionObserver:
import { useInfiniteQuery } from '@tanstack/react-query';
import { useEffect, useRef } from 'react';
export function AutoInfiniteScroll() {
const observerTarget = useRef(null);
const {
data = { pages: [] },
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam = 0 }) =>
fetch(`/api/posts?offset=${pageParam}&limit=20`).then((r) =>
r.json()
),
getNextPageParam: (lastPage, allPages) => {
const fetched = allPages.reduce((s, p) => s + p.items.length, 0);
return fetched `<` lastPage.total ? fetched : undefined;
},
});
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
{ rootMargin: '200px' }
);
if (observerTarget.current) {
observer.observe(observerTarget.current);
}
return () => observer.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
return (
`<div>`
`<ul>`
{data.pages.flatMap((page) =>
page.items.map((item) => `<li key={item.id}>{item.name}`</li>`)
)}
`</ul>`
{isFetchingNextPage && `<p>`Loading...`</p>`}
{!hasNextPage && `<p>`All items loaded.`</p>`}
`<div ref={observerTarget} style={{ height: '1px' }} />`
`</div>`
);
}
Notice data.pages.flatMap(): React Query stores each page separately in data.pages, and you flatten them to render all items.
Cursor Pagination with useInfiniteQuery
For cursor-based APIs, pass the cursor as the pageParam:
async function fetchPostsWithCursor(cursor = null) {
const params = new URLSearchParams({ limit: '20' });
if (cursor) params.append('cursor', cursor);
const response = await fetch(`/api/posts?${params}`);
return response.json(); // { items: [...], nextCursor: "xyz" }
}
export function CursorInfiniteScroll() {
const { data = { pages: [] }, fetchNextPage, hasNextPage } =
useInfiniteQuery({
queryKey: ['posts-cursor'],
queryFn: ({ pageParam = null }) => fetchPostsWithCursor(pageParam),
getNextPageParam: (lastPage) => lastPage.nextCursor || undefined,
initialPageParam: null,
});
return (
`<div>`
`<ul>`
{data.pages.flatMap((page) =>
page.items.map((item) => `<li key={item.id}>{item.name}`</li>`)
)}
`</ul>`
`<button`
onClick={() => fetchNextPage()}
disabled={!hasNextPage}
`>`
Load More
`</button>`
`</div>`
);
}
For cursor pagination, initialPageParam is required and usually null (or an empty cursor).
Handling Errors and Retry
React Query retries failed requests. Customize retry behavior per query:
useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam = 0 }) => fetchPosts(pageParam),
retry: (failureCount, error) => {
// Don't retry 404 or 401
if (error.status === 404 || error.status === 401) return false;
return failureCount `<` 3;
},
getNextPageParam: (lastPage) =>
lastPage.items.length === 20 ? lastPage.nextOffset : undefined,
})
Key Takeaways
useInfiniteQuerymanages multiple pages as a single cache entry.pageParamis the unique identifier for each page; update it viagetNextPageParam.- Combine
useInfiniteQuerywith IntersectionObserver for automatic loading. data.pagesis an array of pages; use.flatMap()to render all items.- For cursor pagination, pass the cursor as
pageParam; for offset, pass the offset number.
Frequently Asked Questions
What's the difference between hasNextPage and isFetchingNextPage?
hasNextPage indicates whether a next page exists (based on getNextPageParam). isFetchingNextPage is true while fetching the next page. Disable the button when either is false or loading.
Can I refetch a specific page?
Yes. Call queryClient.invalidateQueries({ queryKey: ['posts'] }) to refetch all pages, or manually call refetch() from the hook.
Does useInfiniteQuery cache each page separately?
No, it's one cache entry with multiple pages. This means if you refetch, you refetch everything from page 1. To cache pages independently, use useQuery with different keys per page.
How do I implement bidirectional infinite scroll (up and down)?
Use getPreviousPageParam alongside getNextPageParam, and call fetchPreviousPage(). This requires a cursor-based API that can fetch backward.