Skip to the content.

Build an infinite-scroll list

Build a list that loads the next page as the user approaches the bottom.

Starter files are in practice/workspace/infinite-scroll-list/<framework>/.

Solution

Approach 1: an IntersectionObserver on a sentinel

useEffect(() => {
  const node = sentinelRef.current;
  if (!node || done) return;
  const observer = new IntersectionObserver(
    (entries) => { if (entries[0].isIntersecting) loadMore(); },
    { rootMargin: "400px" }     // start loading 400px early
  );
  observer.observe(node);
  return () => observer.disconnect();
}, [loadMore, done]);

rootMargin is what turns “loads when you hit the bottom” into “the next page is already there”. Scroll-event handlers with getBoundingClientRect do the same job while forcing layout on every frame; the observer costs nothing until it fires.

Disconnecting when done is the part people leave out, and it is why exhausted lists keep firing requests at a server that has nothing left to send.

Approach 2: a guarded loader the observer merely triggers

The observer can fire several times before the first response lands, so the guard belongs in the loader, not in the observer:

const inFlight = useRef(false);
const loadMore = useCallback(async () => {
  if (inFlight.current || done) return;
  inFlight.current = true;
  setStatus("loading");
  try {
    const page = await fetchPage(cursor);
    setItems((prev) => [...prev, ...page.items]);
    setCursor(page.nextCursor);
    setDone(!page.nextCursor);
    setStatus("idle");
  } catch (err) {
    setStatus("error");          // keep the items already loaded
  } finally {
    inFlight.current = false;
  }
}, [cursor, done]);

A ref rather than state, because a state update is asynchronous and the second observer callback would read the old value and fire anyway. This is the bug that shows up as duplicated rows the first time someone flicks the scrollbar.

Trade-offs

Cursor vs offset pagination. Offset is simpler and duplicates or skips rows when the underlying list changes between pages — which, in an infinite feed, it always does. Cursors are stable and cannot jump to an arbitrary page, which infinite scroll does not need anyway.

Infinite scroll costs you the footer. Anything below the list becomes unreachable, because the list grows as fast as the user scrolls. If there is a footer with anything in it, use a “Load more” button, or a hybrid: auto-load three pages, then require a click. This is a product decision the component should surface, not hide.

Memory. Ten thousand DOM nodes will make a phone stutter no matter how efficiently you appended them. Past a few thousand rows the answer is virtualisation, and virtualisation plus infinite scroll is meaningfully harder than either alone — so decide which problem you actually have before building both.

Announce the new rows. Appending silently means a screen-reader user’s cursor stays where it was with no indication anything happened. A polite live region saying “20 more results, 120 total” after each page is the minimum, and aria-busy on the list while loading tells them why nothing is moving yet.

Restoring position. Navigating into a row and back should not dump the user at the top of a one-page list. Persist the loaded pages and the scroll offset, or accept that the back button is broken — but know which one you shipped.