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:
- 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
loadMorewhen the sentinel scrolls into view.
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:
cancelledflag in the effect cleanup. If the user unmounts mid-fetch, do not callsetItemson a ghost component.busyguard insideloadMore. Without it, an over-eager observer fires multiple loads before the first response arrives.- The code page | state is the dependency of the effect; bumping it triggers the next fetch. Clean and predictable.
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:
- 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 convert to manual 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. 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
- 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. Wrap the effect dependencies tightly (
) 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.
Keep building
Test loading skeletons with simulated latency
Use ?delay=N to make Go REST take seconds to respond, so you can verify your skeleton screens render without flicker.
Test 401, 422, 429 and 500 handlers
Force any status code with ?force_status= to make sure your error UI never shows blank screens or eats stack traces.
Build a paginated user list in React
A working component that fetches a page, renders rows, and uses X-Pagination-Pages to render Previous/Next correctly.