Infinite scroll list
IntersectionObserver instead of scroll math.
Implement a list that loads more items as the user scrolls to the bottom. Uses IntersectionObserver to detect when a sentinel element enters the viewport — no scroll event math.
The Problem
The naive approach: listen to the scroll event, check if scrollTop + clientHeight >= scrollHeight. This fires on every scroll tick and requires manual calculation.
IntersectionObserver is the modern alternative: place a sentinel element at the bottom of the list, and observe it. When it becomes visible, load more.
The Implementation
function InfiniteList({ fetchPage }) { const [items, setItems] = useState([]); const [page, setPage] = useState(1); const [loading, setLoading] = useState(false); const [hasMore, setHasMore] = useState(true); const sentinelRef = useRef(null);
useEffect(() => { const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting && hasMore && !loading) { loadMore(); } }, { rootMargin: "100px" }, );
if (sentinelRef.current) observer.observe(sentinelRef.current); return () => observer.disconnect(); }, [hasMore, loading, page]);
async function loadMore() { setLoading(true); const { items: newItems, hasMore: more } = await fetchPage(page); setItems((prev) => [...prev, ...newItems]); setPage((p) => p + 1); setHasMore(more); setLoading(false); }
return ( <ul> {items.map((item) => ( <li key={item.id}>{item.name}</li> ))} {loading && <li>Loading...</li>} <li ref={sentinelRef} aria-hidden="true" /> </ul> );}Why rootMargin: '100px'
Loading when the sentinel is already visible means the user sees a flash of loading state. Adding rootMargin triggers the load 100px before the sentinel enters the viewport, so new content is ready before they reach the bottom.
Why It Matters
Scroll event listeners require debouncing and manual geometry calculations. IntersectionObserver is declarative, efficient (browser-native, not main-thread JS), and handles edge cases like viewport resize automatically.