Building Infinite Scroll From Scratch: IntersectionObserver, Not Scroll Handlers
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 part | Naive version | What breaks |
|---|---|---|
| detection | scroll listener + math | fires per-frame, janky, forces layout reads |
| duplicate fetches | fire on every intersection | loads the same page 2-3× (races, StrictMode) |
| stale closures | observer captures page once | always fetches page 1 (or a frozen page) |
| termination | fetch forever | infinite requests past the end of data |
| errors | ignore failures | one failed page = permanently stuck |
GIF 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:
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 };
}
GIF 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.
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]
);
}
GIF 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
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>
);
}
GIF 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
loadMoretwice — but theloadingRefguard 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
useInfiniteQueryis 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 ARIAfeedpattern (role="feed",aria-busywhile loading,aria-setsize/aria-posinseton articles) so screen-reader users can navigate it coherently.
GIF via GIPHY
Key Takeaways
- Use
IntersectionObserveron a sentinel element, not ascrolllistener — it's efficient, off-main-thread, and needs no throttling. SetrootMarginto 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
loadMorereads the current position synchronously rather than from a stale closure. - Model paging as an explicit
idle | loading | error | donemachine;nextCursor === nullis the natural terminator, anderrormust be recoverable (a retry button re-callsloadMore). - 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.
GIF via GIPHYWhat did you think?