Skip to content

Build infinite scroll with IntersectionObserver

Append pages as the sentinel element scrolls into view. Works with any list endpoint that returns pagination headers.

Infinite scroll loads the next batch of items when the user scrolls near the bottom. Done well, it feels seamless. Done poorly, it duplicates rows, loads twice, or breaks accessibility. This recipe shows the IntersectionObserver-based approach, which is the right one today: no scroll-event listeners, no debounce hacks.

The pieces

Three building blocks:

  • A hook that holds the appended items and knows how to load more.
  • A sentinel element rendered after the last item.
  • An IntersectionObserver that fires loadMore when the sentinel scrolls into view.

The hook

Append-only state. Each new page is added to the end of the existing array. Read X-Pagination-Pages to know when to stop.

import { useEffect, useRef, useState } from "react";

function useInfiniteUsers() {
  const [items, setItems]   = useState([]);
  const [page, setPage]     = useState(1);
  const [hasMore, setMore]  = useState(true);
  const [busy, setBusy]     = useState(false);

  useEffect(() => {
    let cancelled = false;
    setBusy(true);
    fetch(`https://gorest.co.in/public/v2/users?page=${page}`)
      .then(async (res) => {
        const total = parseInt(res.headers.get("x-pagination-pages") || "1", 10);
        return [await res.json(), total];
      })
      .then(([batch, total]) => {
        if (cancelled) return;
        setItems(prev => [...prev, ...batch]);
        setMore(page < total);
      })
      .finally(() => !cancelled && setBusy(false));
    return () => { cancelled = true; };
  }, [page]);

  const loadMore = () => !busy && hasMore && setPage(p => p + 1);
  return { items, hasMore, busy, loadMore };
}

Three details that matter:

  • The cancelled flag in the effect cleanup. If the user unmounts mid-fetch, do not call setItems on a component that is no longer there.
  • The busy guard inside loadMore. Without it, an over-eager observer fires multiple loads before the first response arrives.
  • The page state is the dependency of the effect; bumping it triggers the next fetch. Clean and predictable.

The sentinel and observer

Render an empty li after the last item. Observe it. When it scrolls into view, load more.

function UserFeed() {
  const { items, hasMore, busy, loadMore } = useInfiniteUsers();
  const sentinel = useRef(null);

  useEffect(() => {
    if (!sentinel.current) return;
    const io = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) loadMore();
    }, { rootMargin: "200px" });
    io.observe(sentinel.current);
    return () => io.disconnect();
  }, [loadMore]);

  return (
    <ul>
      {items.map(u => <li key={u.id}>{u.name} ({u.email})</li>)}
      {hasMore && (
        <li ref={sentinel} aria-hidden="true">
          {busy ? "Loading..." : "Scroll for more"}
        </li>
      )}
    </ul>
  );
}

The rootMargin of "200px" triggers loading when the sentinel is still 200 px below the viewport, so by the time the user actually reaches the bottom, the next batch is usually rendered. Tune it for your average load time.

Use stable keys

The most common bug in infinite-scroll lists is using the array index as the React key. When you append to the array, indices shift, React reuses the wrong DOM nodes, and rows visibly flicker on scroll. Use the resource id; it never changes.

// BAD: the key is the array index, items shift on update
{items.map((u, i) => <li key={i}>{u.name}</li>)}

// GOOD: the key is the resource id, never changes
{items.map(u => <li key={u.id}>{u.name}</li>)}

Accessibility

Infinite scroll is hard to use for keyboard users and screen readers. Two fixes:

  • Always include a "Load more" button inside the sentinel for keyboard users. The observer triggers it on scroll; pressing Enter triggers it manually.
  • Provide a way to pause. Long lists never reach the footer if loading is automatic. Either offer a "Pause loading" toggle, or switch to manual loading after N pages.

Restoring scroll position

If the user clicks an item, navigates away, and hits Back, they expect to return to the same scroll position with all loaded items still visible. Save the loaded items and scroll offset in sessionStorage on unmount; load them back on mount. React Router 6 handles some of this automatically with unstable_useScrollRestoration; check the version.

Combine with simulated latency

When testing infinite scroll, use ?delay=1500 on the request so you can see your loading indicator. With instant local responses you cannot tell whether the spinner ever shows.

fetch(`/public/v2/users?page=${page}&delay=1500`)

Common mistakes

  • Listening to scroll events instead. Scroll handlers fire dozens of times per second; you debounce; the user scrolls fast and misses a load. IntersectionObserver is the right tool.
  • Forgetting hasMore. Without it, the observer keeps triggering after the last page, and you fetch empty arrays forever.
  • Re-creating the observer on every render. Keep the effect dependencies tight ([loadMore]) and disconnect on cleanup.
  • Mixing infinite scroll and pagination. Pick one. Users hate hybrid lists where you scroll for a while and then hit a "Page 2" button.
More recipes

Keep building