React  

How to Implement Infinite Scrolling in React Using Intersection Observer

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

  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.

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

  • react-window (lightweight)

  • react-virtualized (feature rich)

Virtualization + Intersection Observer pattern:

  • Use Intersection Observer to fetch more data

  • Use virtualization to render only thousands of rows efficiently

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.