patterntypescriptreactTip
TanStack Query — infinite scroll with useInfiniteQuery
Viewed 0 times
useInfiniteQueryinfinite scrollpaginationgetNextPageParamfetchNextPagehasNextPagecursor pagination
Problem
Paginated lists loaded with separate useQuery calls per page require manual state for the current page and accumulated data. useInfiniteQuery handles accumulation, page tracking, and the 'load more' trigger automatically.
Solution
Use useInfiniteQuery with getNextPageParam and flatten pages for rendering:
import { useInfiniteQuery } from '@tanstack/react-query';
import { useInView } from 'react-intersection-observer';
interface Page { posts: Post[]; nextCursor: string | null; }
async function fetchPosts({ pageParam = '' }: { pageParam?: string }): Promise<Page> {
const url = pageParam ?
const res = await fetch(url);
return res.json();
}
function InfinitePostList() {
const { ref, inView } = useInView();
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ['posts', 'infinite'],
queryFn: fetchPosts,
initialPageParam: '',
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
});
// Trigger load when sentinel element enters viewport
useEffect(() => {
if (inView && hasNextPage) fetchNextPage();
}, [inView, hasNextPage, fetchNextPage]);
const allPosts = data?.pages.flatMap((page) => page.posts) ?? [];
return (
<ul>
{allPosts.map((post) => <li key={post.id}>{post.title}</li>)}
<li ref={ref}>{isFetchingNextPage ? 'Loading more...' : null}</li>
</ul>
);
}
import { useInfiniteQuery } from '@tanstack/react-query';
import { useInView } from 'react-intersection-observer';
interface Page { posts: Post[]; nextCursor: string | null; }
async function fetchPosts({ pageParam = '' }: { pageParam?: string }): Promise<Page> {
const url = pageParam ?
/api/posts?cursor=${pageParam} : '/api/posts';const res = await fetch(url);
return res.json();
}
function InfinitePostList() {
const { ref, inView } = useInView();
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ['posts', 'infinite'],
queryFn: fetchPosts,
initialPageParam: '',
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
});
// Trigger load when sentinel element enters viewport
useEffect(() => {
if (inView && hasNextPage) fetchNextPage();
}, [inView, hasNextPage, fetchNextPage]);
const allPosts = data?.pages.flatMap((page) => page.posts) ?? [];
return (
<ul>
{allPosts.map((post) => <li key={post.id}>{post.title}</li>)}
<li ref={ref}>{isFetchingNextPage ? 'Loading more...' : null}</li>
</ul>
);
}
Why
useInfiniteQuery stores each fetched page in data.pages and exposes hasNextPage and fetchNextPage. Returning undefined from getNextPageParam signals the end of pagination and sets hasNextPage to false.
Gotchas
- In v5, initialPageParam is required — it sets the pageParam for the first fetch
- Return undefined (not null) from getNextPageParam to mark the end of pages — null is treated as a valid cursor
- data.pages is an array of page responses — always flatMap to get a flat list for rendering
- Invalidating the query key resets all pages and refetches from page 1
Revisions (0)
No revisions yet.