Back to Blog

Building Infinite Scroll From Scratch: IntersectionObserver, Not Scroll Handlers

Vidhya Sagar ThakurSeptember 29, 202616 min read0 views

The old way to build infinite scroll was a scroll event listener computing scrollTop + clientHeight >= scrollHeight — which fires dozens of times per scroll (jank), needs throttling, and forces layout reads. The modern way is IntersectionObserver: place an invisible sentinel element after the last item, and the browser tells you — efficiently, off the main thread — when it scrolls into view, at which point you load the next page. That's the easy part. The hard parts are the ones that produce the bugs: duplicate fetches (the observer firing again before the previous load finishes, or React 18 StrictMode double-invoking), stale closures (the observer callback capturing an old page), knowing when there's no more data, and error recovery (a failed page load must be retryable, not a dead end). The build in one sentence: an IntersectionObserver watches a sentinel via a callback ref, firing a guarded loadMore that walks an explicit paging state machine with a "hasMore" terminator and a retry path.

What Makes This Hard

Hard partNaive versionWhat breaks
detectionscroll listener + mathfires per-frame, janky, forces layout reads
duplicate fetchesfire on every intersectionloads the same page 2-3× (races, StrictMode)
stale closuresobserver captures page oncealways fetches page 1 (or a frozen page)
terminationfetch foreverinfinite requests past the end of data
errorsignore failuresone failed page = permanently stuck
Confused Oh No GIF by Apartment GuideGIF via GIPHY

Pairs with ARCHITECTURE/decisions/25 (data-fetching) and decisions/40 (cursor pagination — the right backend for infinite scroll).

1. The Paging State Machine

Infinite scroll is a state machine over accumulating pages. Model it explicitly so "loading", "error", and "done" are distinct and can't overlap:

TSX
import { useCallback, useRef, useState } from "react";

type Item = { id: string; title: string };

type Page = { items: Item[]; nextCursor: string | null };

type Status = "idle" | "loading" | "error" | "done";

async function fetchPage(cursor: string | null): Promise<Page> {
  const url = cursor ? `/api/items?cursor=${cursor}` : `/api/items`;
  const res = await fetch(url);
  if (!res.ok) throw new Error(`Failed to load (${res.status})`);
  return res.json();
}

function useInfiniteItems() {
  const [items, setItems] = useState<Item[]>([]);
  const [status, setStatus] = useState<Status>("idle");
  // Cursor and an in-flight guard live in refs — they must be read/written
  // synchronously inside loadMore WITHOUT waiting for a re-render, or two
  // rapid calls would both see the old (stale) values and double-fetch.
  const cursorRef = useRef<string | null>(null);
  const loadingRef = useRef(false);

  const loadMore = useCallback(async () => {
    // Guard: never start a load while one is already in flight, or when done.
    if (loadingRef.current || status === "done") return;
    loadingRef.current = true;
    setStatus("loading");

    try {
      const page = await fetchPage(cursorRef.current);
      setItems((prev) => [...prev, ...page.items]);
      cursorRef.current = page.nextCursor;
      // No next cursor → we've reached the end. Terminate.
      setStatus(page.nextCursor === null ? "done" : "idle");
    } catch (err) {
      setStatus("error"); // recoverable — loadMore can be called again to retry
    } finally {
      loadingRef.current = false;
    }
  }, [status]);

  return { items, status, loadMore };
}
page hearts GIFGIF via GIPHY

The critical decision here is the loadingRef guard using a ref, not state. State updates are asynchronous and batched, so if two intersection events fire in quick succession, both would read the old isLoading === false from a stale render and both fetch — loading the same page twice (duplicates). A ref is written synchronously, so the second call sees loadingRef.current === true immediately and bails. This is the canonical "guard against re-entrancy in async React" pattern. The cursor lives in a ref for the same reason: loadMore must read the current cursor synchronously, not a value captured in a stale closure.

Using a cursor (nextCursor) rather than an offset/page-number is deliberate — it's O(1) and stable under inserts (see decisions/40); nextCursor === null is the natural "no more data" terminator.

2. The Sentinel + Observer (via a Callback Ref)

Now the detection. The subtle correctness issue is when to attach/detach the observer — the sentinel element may mount/unmount (e.g., it's hidden when status === "done"), so a plain useRef + useEffect can attach to a stale node. A callback ref is the robust pattern: React calls it with the node when it mounts and null when it unmounts, giving us exact attach/detach timing.

TSX
import { useCallback, useEffect, useRef } from "react";

/**
 * Calls `onIntersect` when the returned ref's element scrolls into view.
 * `enabled` lets us stop observing while loading or when done.
 */
function useIntersectionObserver(
  onIntersect: () => void,
  enabled: boolean,
  rootMargin = "200px" // fire 200px EARLY, so the next page loads before the
  //                       user actually hits the bottom (no visible wait).
) {
  const observerRef = useRef<IntersectionObserver | null>(null);
  // Keep the latest callback in a ref so the observer always calls the
  // current onIntersect without needing to re-create the observer.
  const savedCallback = useRef(onIntersect);
  useEffect(() => {
    savedCallback.current = onIntersect;
  }, [onIntersect]);

  return useCallback(
    (node: HTMLElement | null) => {
      // Disconnect any previous observation first.
      observerRef.current?.disconnect();
      if (!node || !enabled) return;

      observerRef.current = new IntersectionObserver(
        (entries) => {
          if (entries[0].isIntersecting) savedCallback.current();
        },
        { rootMargin }
      );
      observerRef.current.observe(node);
    },
    [enabled, rootMargin]
  );
}
european space agency animation GIFGIF via GIPHY

Two refinements matter. rootMargin: "200px" makes the observer fire before the sentinel is actually visible — so the next page is already loading as the user approaches the bottom, and they never see a spinner mid-scroll (a perceived-performance win). And the saved-callback ref decouples "the observer instance" from "the latest onIntersect", so we don't tear down and recreate the observer every render just because the callback identity changed.

3. Wiring It Together

TSX
export function InfiniteList() {
  const { items, status, loadMore } = useInfiniteItems();

  // Observe only while there's more to load and we're not mid-load/error.
  const sentinelRef = useIntersectionObserver(
    loadMore,
    status === "idle"
  );

  // Kick off the first page on mount.
  useEffect(() => {
    if (status === "idle" && items.length === 0) loadMore();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <div className="infinite-list">
      <ul>
        {items.map((item) => (
          <li key={item.id}>{item.title}</li>
        ))}
      </ul>

      {status === "loading" && <p aria-live="polite">Loading more…</p>}

      {status === "error" && (
        <div role="alert">
          <p>Couldn’t load more items.</p>
          <button onClick={loadMore}>Retry</button>
        </div>
      )}

      {status === "done" && <p>You’ve reached the end.</p>}

      {/* The sentinel: an empty element after the list. Only rendered while
          more data may exist, so the observer stops firing when done. */}
      {status === "idle" && <div ref={sentinelRef} aria-hidden="true" />}
    </div>
  );
}
Sheet Sheeeeeit GIFGIF via GIPHY

The sentinel is only rendered when status === "idle" — so once we're done (or loading, or in error), it unmounts, the callback ref fires with null, and the observer disconnects. This is what stops the "fetch forever" and "fetch while already loading" problems structurally: no sentinel in view → no loadMore calls.

Edge Cases & Gotchas

  • StrictMode double-mount. In React 18 dev StrictMode, the mount effect runs twice, which would fire the initial loadMore twice — but the loadingRef guard absorbs the second call (it sees a load already in flight). This is exactly why the guard uses a ref, not state.
  • The "short content" trap. If the first page doesn't fill the viewport, the sentinel is already visible, so the observer fires immediately for page 2 — good, that's correct (keep loading until the viewport fills or data ends). But make sure the sentinel is genuinely after the content, or it can intersect before any items render.
  • Duplicate keys. If the backend returns overlapping items across pages (common with offset pagination on changing data — the exact bug in decisions/40), you'll get React duplicate-key warnings and doubled rows. Cursor pagination avoids this; if stuck with offset, dedupe by id when appending.
  • Scroll restoration. Navigating away and back loses the accumulated pages (they're in component state). For a feed users return to, lift the accumulated items + cursor into a cache (React Query's useInfiniteQuery is built for exactly this and handles cursor management, dedup, and caching).
  • Combining with virtualization. For very long infinite lists, the accumulated DOM eventually gets heavy — combine infinite scroll (data) with virtualization (rendering, build 03) so the DOM stays bounded regardless of how many pages have loaded.
  • role="feed". For an accessible infinite feed, consider the ARIA feed pattern (role="feed", aria-busy while loading, aria-setsize/aria-posinset on articles) so screen-reader users can navigate it coherently.
puppy discover GIFGIF via GIPHY

Key Takeaways

  • Use IntersectionObserver on a sentinel element, not a scroll listener — it's efficient, off-main-thread, and needs no throttling. Set rootMargin to fire early so the next page loads before the user hits the bottom.
  • Guard against duplicate fetches with a ref (loadingRef), not state — refs update synchronously, so rapid re-entrant calls (and StrictMode's double-invoke) see the in-flight flag immediately and bail. State updates are async and would let both calls through.
  • Keep the cursor in a ref too, so loadMore reads the current position synchronously rather than from a stale closure.
  • Model paging as an explicit idle | loading | error | done machine; nextCursor === null is the natural terminator, and error must be recoverable (a retry button re-calls loadMore).
  • Attach the observer with a callback ref so mount/unmount of the sentinel gives exact attach/detach timing — and unmount the sentinel when loading/done/error so the observer stops firing structurally.
  • In production, reach for React Query's useInfiniteQuery — it handles cursor management, dedup, caching, and scroll restoration (the hard parts) out of the box.
Listen Episode 11 GIF by The BachelorGIF via GIPHY

What did you think?

© 2026 Vidhya Sagar Thakur. All rights reserved.