Introduction
Infinite scrolling is a common pattern for loading more content as a user reaches the end of a list or page. It improves user experience by letting users keep browsing without clicking "Next". The modern and efficient way to implement infinite scroll in a React app is to use the browser's Intersection Observer API. This API watches a DOM element and tells you when it appears in the viewport, so you can trigger a data load exactly when needed.
What is Intersection Observer and why use it?
The Intersection Observer API lets you observe when an element (the "target" or "sentinel") enters or leaves the browser viewport. It avoids expensive scroll listeners and manual calculations.
Why prefer Intersection Observer:
Uses browser-native APIs for efficiency
Avoids continuous scroll event handlers and layout thrashing
Works well with React functional components and hooks
Supports options like root, rootMargin, and threshold for precise control
Basic idea of infinite scrolling
Render the initial list of items.
Place a small empty element (a sentinel) after the list.
Observe that sentinel with Intersection Observer.
When the sentinel becomes visible, fetch the next page of data and append it to the list.
Repeat until there is no more data.
Simple React example (step-by-step)
This example uses functional React components, useState, and useEffect. It assumes you have an API that returns paginated results.
"use client";
import React, { useEffect, useRef, useState, useCallback } from "react";
function useInfiniteScroll(fetchFunction) {
const [items, setItems] = useState([]);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(false);
const sentinelRef = useRef(null);
const loadMore = useCallback(async () => {
if (loading || !hasMore) return;
setLoading(true);
try {
const { data, nextPage } = await fetchFunction(page);
setItems(prev => [...prev, ...data]);
if (!nextPage) setHasMore(false);
else setPage(nextPage);
} catch (err) {
console.error("Fetch error", err);
} finally {
setLoading(false);
}
}, [fetchFunction, page, loading, hasMore]);
useEffect(() => {
const node = sentinelRef.current;
if (!node) return;
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
loadMore();
}
});
});
observer.observe(node);
return () => observer.disconnect();
}, [loadMore]);
return { items, loading, hasMore, sentinelRef };
}
// Example fetch function. Replace with your real API call.
async function fetchPosts(page) {
// Example: GET /api/posts?page=1
const res = await fetch(`/api/posts?page=${page}`);
if (!res.ok) throw new Error("Network response was not ok");
const json = await res.json();
return { data: json.items, nextPage: json.nextPage };
}
export default function PostList() {
const { items, loading, hasMore, sentinelRef } = useInfiniteScroll(fetchPosts);
return (
<div>
<ul>
{items.map(item => (
<li key={item.id}>{item.title}</li>
))}
</ul>
{loading && <p>Loading...</p>}
{!hasMore && <p>No more posts</p>}
{/* Sentinel element */}
<div ref={sentinelRef} style={{ height: 1 }} aria-hidden="true" />
</div>
);
}
Detailed explanation of the example
useInfiniteScrollis a custom hook that manages state and the Intersection Observer.sentinelRefpoints to a small element at the end of the list. When it becomes visible, the hook callsloadMore().fetchPostsis a placeholder that calls your paginated API.The hook tracks
page,loading, andhasMoreto prevent duplicate requests and to stop when there is no more data.

Join the conversation! Your thoughts help the community grow.