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
useInfiniteScroll is a custom hook that manages state and the Intersection Observer.
sentinelRef points to a small element at the end of the list. When it becomes visible, the hook calls loadMore().
fetchPosts is a placeholder that calls your paginated API.
The hook tracks page, loading, and hasMore to prevent duplicate requests and to stop when there is no more data.
Handling API pagination and responses
Your API should return a clear structure. Common patterns:
{ items: [...], nextPage: 2 } — return the next page number or null when finished
{ items: [...], total: 100 } — return total count so the client can compute if more pages exist
Use limit and offset or page and pageSize consistently
Always handle errors and broken responses gracefully: show a user-friendly message and allow retry.
Dealing with rapid scrolling and duplicate calls
Even with Intersection Observer, you can get duplicate fetches if the observer triggers multiple times quickly. Strategies:
Track loading flag and skip new fetches while loading
Use a request queue or debounce calls inside the hook
Use the server to provide nextPage so client uses server truth
Example debounce inside loadMore with a small delay:
let debounceTimer = null;
function debounceLoad() {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
loadMore();
}, 200);
}
RTL and accessibility considerations
Use semantic HTML (lists with <ul> / <li>)
Provide visible loading indicators
Allow keyboard users to reach the bottom (ensure sentinel does not trap focus)
Use aria-live for dynamic content announcements if necessary
Example
<p aria-live="polite">{loading ? "Loading more posts" : ""}</p>
Handling errors and retries
Show an error message and let users retry manually.
{error && (
<div>
<p>Something went wrong. <button onClick={() => retry()}>Retry</button></p>
</div>
)}
Provide a retry function that re-calls the fetch for the same page.
Performance tips and virtualization
Infinite lists with many DOM nodes can slow a page. Use virtualization to render only visible items.
Popular libraries:
Virtualization + Intersection Observer pattern:
Fallbacks for older browsers
Intersection Observer is supported by modern browsers, but for old browsers include a polyfill:
<script src="https://polyfill.io/v3/polyfill.min.js?features=IntersectionObserver"></script>
Alternatively, fall back to a throttled scroll listener:
window.addEventListener('scroll', throttle(() => {
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 100) {
loadMore();
}
}, 200));
Server-side rendering and SEO
Infinite scroll can hide content from search engines if pages are not linkable. To keep good SEO:
Provide server-rendered initial content (first page)
Expose paginated pages with unique URLs (e.g., /posts?page=2) and link to them
Use rel="next" / rel="prev" in <head> for pagination
Testing and monitoring
Write tests for the hook logic (mock IntersectionObserver)
Monitor network calls in production and watch for duplicate requests
Track user behavior (how far users scroll) to tune page size
Common pitfalls to avoid
Observing an element that moves or resizes frequently
Not cleaning up the observer on unmount
Using a large sentinel (makes trigger unpredictable)
Forgetting to stop when hasMore is false
Example with react-intersection-observer hook
If you prefer a ready-made hook, react-intersection-observer simplifies usage.
import { useInView } from 'react-intersection-observer';
export default function PostList() {
const [ref, inView] = useInView();
useEffect(() => {
if (inView) loadMore();
}, [inView]);
return (
<div>
<ul>...</ul>
<div ref={ref} />
</div>
);
}
This reduces boilerplate and handles common edge cases.
Summary
Infinite scrolling with Intersection Observer is efficient and easy to implement in React. Use a sentinel element, prevent duplicate fetches, show clear loading states, and combine virtualization for large lists. Add fallbacks for older browsers and ensure your app remains accessible. With these practices, you can build smooth infinite-scroll experiences that work well across devices and regions.