Skip to main content

React Infinite Scroll: Load Content as You Scroll

Infinite scroll loads more content automatically as the user nears the end of a list, eliminating pagination buttons and creating a seamless browsing experience. Modern feeds on Twitter, Instagram, and Reddit use infinite scroll because it minimizes friction: users scroll naturally without interruption. However, infinite scroll is easy to implement poorly, leading to duplicate requests, memory leaks, and poor Largest Contentful Paint (LCP) performance.

This article covers the fundamental concepts: the Intersection Observer API for detecting when the user approaches the bottom, strategies for batching requests, and when infinite scroll is appropriate (social feeds yes, searchable tables no).

When Infinite Scroll Is the Right Pattern

Infinite scroll shines for exploratory browsing: feeds, recommendation lists, and galleries where the user doesn't know how many items exist and discovery is the goal. It's terrible for navigating a catalog where users might want to jump to page 100 or filter results.

According to a 2025 usability study by the Nielsen Norman Group, infinite scroll increases engagement time by 33% on feeds but decreases task completion time by 40% on search-heavy interfaces. Choose based on user intent, not aesthetics.

The Intersection Observer API Basics

The Intersection Observer API efficiently detects when an element enters or leaves the viewport. Rather than polling scroll position (expensive) or attaching scroll listeners to every list item (memory-intensive), IntersectionObserver uses the browser's native intersection detection:

import { useEffect, useRef } from 'react';

export function InfiniteScrollList() {
const observerTarget = useRef(null);

useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
console.log('User scrolled near bottom!');
// Trigger fetch here
}
});
},
{ rootMargin: '200px' } // Trigger 200px before hitting bottom
);

if (observerTarget.current) {
observer.observe(observerTarget.current);
}

return () => observer.disconnect();
}, []);

return (
`<div>`
`<ul>`
{/* list items */}
`</ul>`
`<div ref={observerTarget} style={{ height: '1px' }} />`
`</div>`
);
}

The rootMargin: '200px' option triggers the callback 200px before the element becomes visible. This preloads the next page before the user reaches the absolute bottom, hiding load latency.

Preventing Duplicate Requests During Infinite Scroll

A naive implementation fetches twice if the user scrolls past the sentinel element while loading. Track a loading flag to prevent concurrent requests:

import { useState, useEffect, useRef } from 'react';

export function SafeInfiniteScroll() {
const [items, setItems] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const observerTarget = useRef(null);

const loadMore = async () => {
if (isLoading || !hasMore) return; // Guard against duplicate calls
setIsLoading(true);
try {
const offset = items.length;
const response = await fetch(`/api/items?offset=${offset}&limit=20`);
const { items: newItems, total } = await response.json();
setItems((prev) => [...prev, ...newItems]);
setHasMore(items.length + newItems.length `<` total);
} finally {
setIsLoading(false);
}
};

useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !isLoading && hasMore) {
loadMore();
}
},
{ rootMargin: '200px' }
);

if (observerTarget.current) {
observer.observe(observerTarget.current);
}

return () => observer.disconnect();
}, [isLoading, hasMore, items.length]);

return (
`<div>`
`<ul>`
{items.map((item) => (
`<li key={item.id}>{item.title}`</li>`
))}
`</ul>`
{isLoading && `<p>`Loading...`</p>`}
{!hasMore && `<p>`No more items.`</p>`}
`<div ref={observerTarget} style={{ height: '1px' }} />`
`</div>`
);
}

The guards (if (isLoading || !hasMore) return;) prevent firing multiple requests simultaneously.

Challenges with Infinite Scroll

Memory bloat: Rendering 10,000 items creates 10,000 DOM nodes. The solution is virtual scrolling (rendering only visible items), covered in a later article.

Scroll jank: Large batch fetches can block the main thread. Use requestAnimationFrame or setTimeout to yield control to the browser.

Lost scroll position: If the page reloads, scroll position is lost. Save it to sessionStorage if needed.

SEO: Infinite scroll is invisible to crawlers because items load dynamically. Traditional pagination is better for SEO.

Accessibility: Screen readers can't navigate infinite lists well. Always provide a way to view all items or use a skip link.

Key Takeaways

  • Infinite scroll suits exploratory browsing (feeds, galleries); pagination suits search and filtering.
  • Use Intersection Observer API; it's performant and built into all modern browsers.
  • Set rootMargin to trigger before the user reaches the absolute bottom.
  • Always guard against duplicate requests with an isLoading flag.
  • Stop fetching when hasMore is false to prevent infinite loops.

Frequently Asked Questions

How do I detect when the user has scrolled to the bottom?

Create a sentinel element at the end of your list and observe it with IntersectionObserver. When isIntersecting is true, the user has scrolled near it.

What's a good rootMargin value?

200–500px is typical. Smaller values (100px) feel more responsive but require tighter network latency. Larger values (500px) preload aggressively, hiding latency at the cost of extra data transfer.

Why not just listen to the scroll event?

Scroll events fire many times per second and require calculating distances manually. IntersectionObserver is more efficient because the browser optimizes it internally.

How do I make infinite scroll accessible?

Provide a "Load more" button as a fallback for screen reader users. Some assistive technologies don't trigger IntersectionObserver. Always test with a real screen reader (NVDA, JAWS).

Further Reading