Learn the concept
Advanced Patterns
Infinite scroll uses Intersection Observer to detect when a sentinel element near the bottom of the list enters the viewport, triggering a fetch for the next page of data. A ref-based approach with proper cleanup prevents race conditions and duplicate requests.
Core Mechanism:
Place a sentinel <div> at the bottom of the list. Attach an IntersectionObserver to it. When the sentinel becomes visible (enters the viewport), fetch the next page of data and append it to the existing list.
Implementation Steps:
items (array), page (number), loading (boolean), hasMore (boolean)useRef attached to a div after the last itemuseEffect, create an IntersectionObserver watching the sentinel. On intersection, increment the page.useEffect triggered by page changes that fetches data and appends to items.Race Condition Prevention:
loading guard: don't trigger a new fetch if one is in progressAbortController to cancel stale requests when the component unmounts or the page changesref for the loading state (not just state) since the observer callback captures stale closuresIntersection Observer Options:
root: null — observe relative to the viewportrootMargin: '200px' — start loading 200px before the sentinel is visible (for smoother UX)threshold: 0 — trigger as soon as any part of the sentinel is visibleEdge Cases:
hasMore = false and stop observingPerformance Optimizations:
react-window) to only render visible DOM nodesloading="lazy" on images within list itemsReact.memo to prevent re-renders when new items are appendedimport { useState, useEffect, useRef, useCallback } from 'react';
function useInfiniteScroll(fetchPage) {
const [items, setItems] = useState([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const loadingRef = useRef(false);
const observerRef = useRef(null);
// Fetch data when page changes
useEffect(() => {
const controller = new AbortController();
let cancelled = false;
async function load() {
if (loadingRef.current || !hasMore) return;
loadingRef.current = true;
setLoading(true);
try {
const newItems = await fetchPage(page, controller.signal);
if (!cancelled) {
setItems(prev => [...prev, ...newItems]);
if (newItems.length === 0) setHasMore(false);
}
} catch (err) {
if (err.name !== 'AbortError') console.error(err);
} finally {
if (!cancelled) {
setLoading(false);
loadingRef.current = false;
}
}
}
load();
return () => { cancelled = true; controller.abort(); };
}, [page, fetchPage, hasMore]);
// Sentinel ref callback for Intersection Observer
const sentinelRef = useCallback((node) => {
if (observerRef.current) observerRef.current.disconnect();
if (!node) return;
observerRef.current = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && !loadingRef.current) {
setPage(p => p + 1);
}
},
{ rootMargin: '200px' }
);
observerRef.current.observe(node);
}, []);
return { items, loading, hasMore, sentinelRef };
}
// Usage: Product listing with infinite scroll
const fetchProducts = async (page, signal) => {
const res = await fetch(`/api/products?page=${page}&limit=20`, { signal });
const data = await res.json();
return data.products; // Returns [] when no more
};
function ProductList() {
const { items, loading, hasMore, sentinelRef } = useInfiniteScroll(fetchProducts);
return (
<div>
<h1>Products</h1>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))', gap: 16 }}>
{items.map(product => (
<div key={product.id} style={{ border: '1px solid #ddd', padding: 16, borderRadius: 8 }}>
<img src={product.image} alt={product.name} loading="lazy"
style={{ width: '100%', height: 200, objectFit: 'cover' }} />
<h3>{product.name}</h3>
<p>${product.price}</p>
</div>
))}
</div>
{/* Sentinel element — triggers next page load */}
{hasMore && <div ref={sentinelRef} style={{ height: 1 }} />}
{loading && <p style={{ textAlign: 'center', padding: 16 }}>Loading more...</p>}
{!hasMore && items.length > 0 && (
<p style={{ textAlign: 'center', padding: 16, color: '#888' }}>
You've reached the end
</p>
)}
</div>
);
}Instagram, Twitter, and LinkedIn use infinite scroll to load posts seamlessly as users scroll through their feeds.
Food delivery and shopping apps load more restaurants or products as the user scrolls down, avoiding the friction of explicit pagination buttons.
Messaging apps like Slack load older messages when the user scrolls to the top of a conversation, using reverse infinite scroll.
Build an Unsplash-style image gallery that fetches and displays photos in a masonry grid with infinite scroll, loading skeletons, and error retry.
Combine infinite scroll with react-window virtualization to handle 100,000+ items with constant memory usage and smooth scrolling.
Infinite scroll is a common Swiggy machine coding pattern. Their restaurant listing page uses it to progressively load restaurants as users scroll.