Go REST

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, double-loads, or breaks accessibility. This recipe shows the IntersectionObserver-based approach, which is the right one in 2025: no scroll-event listeners, no debounce hacks.

The pieces

Three building blocks:

The hook

Append-only state. Each new page concatenates to the existing array. ReadX-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 sentinel + observer

Render an emptyli 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>
  );
}

TherootMargin 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 genuinely hostile to keyboard users and screen readers. Two mitigations:

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. Persist the loaded items + scroll offset insessionStorage on unmount; rehydrate on mount. React Router 6 handles some of this automatically withunstable_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

More recipes

Keep building

All recipes Integration guides