Infinite Scroll in React with IntersectionObserver

Put an empty sentinel element after the last item and watch it with IntersectionObserver. When it comes within a few hundred pixels of the viewport, fetch the next page and append it. A twenty line hook does this without a library. Once the list reaches thousands of rows, add virtualisation so only visible rows exist in the DOM.

IntersectionObserver options that matter for infinite scroll
OptionDefaultUse for infinite scroll
rootThe viewportSet to the scrolling box if the list scrolls inside a panel
rootMargin0px 0px 0px 0pxSomething like 0px 0px 400px 0px to start loading early
threshold0Leave at 0: any part of the sentinel showing is enough

Updated

Both demos below run on this page. The first loads fake pages of results as you scroll its panel; the second scrolls through ten thousand rows while only drawing the twenty or so you can see. The code for each is underneath, ready to copy.

Demo: a feed that loads as you scroll

Scroll inside the panel. A new page of ten items arrives each time the sentinel comes into range, up to eight pages.

Demo: 10,000 rows, only the visible ones rendered

Scroll the list and watch how few rows actually exist in the page at any moment.

The useInfiniteScroll hook

useInfiniteScroll.ts

Returns a ref for the sentinel. The latest onLoadMore is kept in a ref so the observer is created once per enabled change, not every render.

import { useEffect, useRef } from "react";

export function useInfiniteScroll(onLoadMore: () => void, enabled: boolean, rootMargin = "0px 0px 400px 0px") {
  const sentinel = useRef<HTMLDivElement>(null);
  const latest = useRef(onLoadMore);
  latest.current = onLoadMore;

  useEffect(() => {
    const node = sentinel.current;
    if (!node || !enabled) return;
    const observer = new IntersectionObserver(
      (entries) => { if (entries[0].isIntersecting) latest.current(); },
      { rootMargin },
    );
    observer.observe(node);
    return () => observer.disconnect();
  }, [enabled, rootMargin]);

  return sentinel;
}

Feed.tsx using the hook

The loading flag stops double requests, and hasMore switches the observer off at the end.

function Feed() {
  const [items, setItems] = useState([]);
  const [page, setPage] = useState(1);
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);

  async function loadMore() {
    if (loading) return;
    setLoading(true);
    const res = await fetch(`/api/posts?page=${page}`);
    const { posts, nextPage } = await res.json();
    setItems((old) => [...old, ...posts]);
    setPage(nextPage ?? page);
    setHasMore(Boolean(nextPage));
    setLoading(false);
  }

  const sentinel = useInfiniteScroll(loadMore, hasMore && !loading);

  return (
    <>
      <ul>{items.map((p) => <li key={p.id}>{p.title}</li>)}</ul>
      {hasMore ? <div ref={sentinel}>{loading ? "Loading..." : ""}</div> : <p>You have reached the end.</p>}
      {hasMore && <button onClick={loadMore} disabled={loading}>Load more</button>}
    </>
  );
}

Virtualising a long list with TanStack Virtual

Install with npm install @tanstack/react-virtual. The outer box scrolls, the inner box is as tall as the whole list, and each visible row is moved into place with translateY.

VirtualList.jsx

Only rows near the visible window are rendered. estimateSize is the row height in pixels.

import { useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";

export function VirtualList({ rows }) {
  const parentRef = useRef(null);
  const virtualizer = useVirtualizer({
    count: rows.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 36,
  });

  return (
    <div ref={parentRef} style={{ height: 400, overflow: "auto" }}>
      <div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
        {virtualizer.getVirtualItems().map((item) => (
          <div key={item.key} style={{ position: "absolute", top: 0, left: 0, width: "100%",
                height: item.size, transform: `translateY(${item.start}px)` }}>
            {rows[item.index]}
          </div>
        ))}
      </div>
    </div>
  );
}

Why a sentinel beats scroll events

The old approach listened to scroll and measured scrollTop against scrollHeight on every event. That runs dozens of times a second on the main thread and needs throttling to stay smooth.

An observer lets the browser do the geometry and calls you only when the sentinel crosses the margin you set. It costs nothing while the person reads, and the same code works for window scrolling or a scrolling panel.

Loading, errors and the end of the list

Track three things beside the items: whether a page is loading, whether the last request failed, and whether more pages exist. Render a small spinner while loading, a retry button on failure, and a clear end message when the server says there is nothing left.

Cursor-based APIs, which return a token for the next page, are safer than offsets when new items arrive at the top of a feed, because offsets shift and cause duplicates.

How it works

  1. Scroll the feed panel. When the sentinel nears the bottom, the next page of items is added.
  2. Watch the counter: loading stops at the last page and a message replaces the sentinel.
  3. Scroll the virtual list and compare the rendered row count with the total.
  4. Copy the useInfiniteScroll hook and swap the fake loader for your API call.

Tips

Frequently asked questions

Do I need a library for infinite scroll in React?

No. IntersectionObserver has been available in all major browsers since 2019, and a small hook covers the common case. A data-fetching library with built-in paging helpers is worth it when you already use one for caching requests.

What is virtualisation and when do I need it?

Virtualisation renders only the rows inside the visible window plus a small buffer, positioned inside a spacer the full height of the list. Reach for it when the DOM holds thousands of rows and scrolling or typing starts to lag. @tanstack/react-virtual and its useVirtualizer hook are a common choice.

Is infinite scroll bad for SEO?

Search crawlers do not scroll like people do, so content that only appears after scrolling may never be seen. Back the feed with real paginated URLs such as ?page=2 that load on their own, and link to them.

Why does my observer fire again and again?

Usually the effect re-creates the observer on every render because the callback changes identity, or the first page is too short to push the sentinel off screen. Keep the latest callback in a ref and make sure each page fills more than the viewport.

Related: Debounce vs throttle, React drag and drop, React table examples, JavaScript async fetch, React performance APIs.

Start for free or see all free tools. We.Inc is free to start, with no card needed. AI building is on paid plans from $20 a month.

More in Guides

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap