useInfiniteQuery and Intersection Observer: The Standard for Infinite Scroll

Chris is building a product list for an e-commerce site. Initially, he placed traditional pagination buttons like [1] [2] [3] at the bottom.
However, testing it on mobile, he found the buttons were too small to press comfortably, and the browsing flow felt disjointed and interrupted.
"Modern apps just keep loading more content as you scroll down. Can't we change ours to work like that?"
He's referring to Infinite Scroll, which provides a seamless user experience like Instagram or Twitter. To implement this, we need a technology to manage "accumulating" data and a way to detect if the user has "hit the bottom."
Today, we will explore how to implement perfect infinite scroll by combining TanStack Query's useInfiniteQuery and the browser's Intersection Observer API.
1. Data Management: useInfiniteQuery
Standard useQuery overwrites existing data when the page number changes. However, infinite scroll needs to "append" new data after the existing data. This is where useInfiniteQuery comes in.
Core Concepts: pageParam and getNextPageParam
// useProducts.ts
import { useInfiniteQuery } from '@tanstack/react-query';
export function useProducts() {
return useInfiniteQuery({
queryKey: ['products'],
queryFn: async ({ pageParam = 1 }) => {
// API request using pageParam
const res = await fetch(`/api/products?page=${pageParam}`);
return res.json();
},
initialPageParam: 1,
// Logic to calculate the next page
getNextPageParam: (lastPage, allPages) => {
// If the last page has fewer than 10 items, consider it finished
const nextPage = allPages.length + 1;
return lastPage.items.length < 10 ? undefined : nextPage;
},
});
}By doing this, data stacks up neatly inside an array called data.pages (e.g., [[Page 1 Data], [Page 2 Data], ...]).
2. Bottom Detection: Intersection Observer
Now we need to determine if the user has scrolled to the bottom.
In the past, we calculated this by attaching a scroll event listener, but this negatively impacts performance (triggers Reflow).
Nowadays, we use the Intersection Observer API. We plant a transparent bar (Sentinel) at the bottom of the screen and watch to see if this bar appears (Intersect) on the screen.
Creating a Custom Hook
Setting up the Observer every time is tedious, so let's create a reusable hook.
// useIntersectionObserver.ts
import { useEffect, useRef } from 'react';
export function useIntersectionObserver(
onIntersect: () => void, // Function to execute when detected
options?: IntersectionObserverInit
) {
const targetRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!targetRef.current) return;
const observer = new IntersectionObserver((entries) => {
// If target enters the screen (isIntersecting), run callback
if (entries[0].isIntersecting) {
onIntersect();
}
}, options);
observer.observe(targetRef.current);
return () => observer.disconnect();
}, [onIntersect, options]);
return targetRef;
}3. Integration: The Infinite Scroll Component
Now, let's combine the two technologies. We place a div at the very end of the list, and when that div becomes visible, we execute fetchNextPage().
// ProductList.tsx
import { useProducts } from './useProducts';
import { useIntersectionObserver } from './useIntersectionObserver';
function ProductList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage
} = useProducts();
// Execute fetchNextPage when hitting the bottom
const loadMoreRef = useIntersectionObserver(() => {
if (hasNextPage) fetchNextPage();
});
return (
<div>
{/* 1. Flatten and render accumulated data */}
{data?.pages.map((page) =>
page.items.map((product) => (
<ProductCard key={product.id} data={product} />
))
)}
{/* 2. Loading indicator and Sentinel for detection */}
<div ref={loadMoreRef} style={{ height: '20px' }}>
{isFetchingNextPage && <Spinner />}
</div>
</div>
);
}4. UX Details: Bidirectional Scroll and Virtualization
What happens if the data grows to 100 or 1000 items? The browser will lag because there are too many DOM nodes.
The technology needed here is Virtualization. It draws only the parts visible on the screen and removes the parts that have passed. We will cover this in detail in the next part (Optimization).
Also, for cases like chat rooms where you need to load past data when scrolling up, you might need bidirectional infinite scroll. useInfiniteQuery also supports getPreviousPageParam, so you can implement it using the same principle.
Key Takeaways
This concludes Part 3: Server State and Async Data.
Now that we've learned how to fetch data effectively, it's time to learn techniques to make the app faster and lighter. If you call an API every time a user types in a search bar, the server won't survive.
Part 4: Optimization & Security begins with the first topic: “useDebounce vs useThrottle: Optimizing Search Autocomplete and Scroll Events”