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:

Basic idea of infinite scrolling

  1. Render the initial list of items.

  2. Place a small empty element (a sentinel) after the list.

  3. Observe that sentinel with Intersection Observer.

  4. When the sentinel becomes visible, fetch the next page of data and append it to the list.

  5. 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.

JavaScript
"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

Handling API pagination and responses

Your API should return a clear structure. Common patterns:

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:

Example debounce inside loadMore with a small delay:

JavaScript
let debounceTimer = null;
function debounceLoad() {
  if (debounceTimer) clearTimeout(debounceTimer);
  debounceTimer = setTimeout(() => {
    loadMore();
  }, 200);
}

RTL and accessibility considerations

Example

JavaScript
<p aria-live="polite">{loading ? "Loading more posts" : ""}</p>

Handling errors and retries

Show an error message and let users retry manually.

JavaScript
{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:

HTML / XML
<script src="https://polyfill.io/v3/polyfill.min.js?features=IntersectionObserver"></script>

Alternatively, fall back to a throttled scroll listener:

JavaScript
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:

Testing and monitoring

Common pitfalls to avoid

Example with react-intersection-observer hook

If you prefer a ready-made hook, react-intersection-observer simplifies usage.

JavaScript
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.