Put an empty sentinel element after the last item and watch it with IntersectionObserver. When it comes within a few hundred pixels of the viewport, fetch the next page and append it. A twenty line hook does this without a library. Once the list reaches thousands of rows, add virtualisation so only visible rows exist in the DOM.
| Option | Default | Use for infinite scroll |
|---|---|---|
| root | The viewport | Set to the scrolling box if the list scrolls inside a panel |
| rootMargin | 0px 0px 0px 0px | Something like 0px 0px 400px 0px to start loading early |
| threshold | 0 | Leave at 0: any part of the sentinel showing is enough |
Updated
Both demos below run on this page. The first loads fake pages of results as you scroll its panel; the second scrolls through ten thousand rows while only drawing the twenty or so you can see. The code for each is underneath, ready to copy.
Scroll inside the panel. A new page of ten items arrives each time the sentinel comes into range, up to eight pages.
Scroll the list and watch how few rows actually exist in the page at any moment.
Returns a ref for the sentinel. The latest onLoadMore is kept in a ref so the observer is created once per enabled change, not every render.
import { useEffect, useRef } from "react";
export function useInfiniteScroll(onLoadMore: () => void, enabled: boolean, rootMargin = "0px 0px 400px 0px") {
const sentinel = useRef<HTMLDivElement>(null);
const latest = useRef(onLoadMore);
latest.current = onLoadMore;
useEffect(() => {
const node = sentinel.current;
if (!node || !enabled) return;
const observer = new IntersectionObserver(
(entries) => { if (entries[0].isIntersecting) latest.current(); },
{ rootMargin },
);
observer.observe(node);
return () => observer.disconnect();
}, [enabled, rootMargin]);
return sentinel;
}The loading flag stops double requests, and hasMore switches the observer off at the end.
function Feed() {
const [items, setItems] = useState([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
async function loadMore() {
if (loading) return;
setLoading(true);
const res = await fetch(`/api/posts?page=${page}`);
const { posts, nextPage } = await res.json();
setItems((old) => [...old, ...posts]);
setPage(nextPage ?? page);
setHasMore(Boolean(nextPage));
setLoading(false);
}
const sentinel = useInfiniteScroll(loadMore, hasMore && !loading);
return (
<>
<ul>{items.map((p) => <li key={p.id}>{p.title}</li>)}</ul>
{hasMore ? <div ref={sentinel}>{loading ? "Loading..." : ""}</div> : <p>You have reached the end.</p>}
{hasMore && <button onClick={loadMore} disabled={loading}>Load more</button>}
</>
);
}Install with npm install @tanstack/react-virtual. The outer box scrolls, the inner box is as tall as the whole list, and each visible row is moved into place with translateY.
Only rows near the visible window are rendered. estimateSize is the row height in pixels.
import { useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
export function VirtualList({ rows }) {
const parentRef = useRef(null);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 36,
});
return (
<div ref={parentRef} style={{ height: 400, overflow: "auto" }}>
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
{virtualizer.getVirtualItems().map((item) => (
<div key={item.key} style={{ position: "absolute", top: 0, left: 0, width: "100%",
height: item.size, transform: `translateY(${item.start}px)` }}>
{rows[item.index]}
</div>
))}
</div>
</div>
);
}
The old approach listened to scroll and measured scrollTop against scrollHeight on every event. That runs dozens of times a second on the main thread and needs throttling to stay smooth.
An observer lets the browser do the geometry and calls you only when the sentinel crosses the margin you set. It costs nothing while the person reads, and the same code works for window scrolling or a scrolling panel.
Track three things beside the items: whether a page is loading, whether the last request failed, and whether more pages exist. Render a small spinner while loading, a retry button on failure, and a clear end message when the server says there is nothing left.
Cursor-based APIs, which return a token for the next page, are safer than offsets when new items arrive at the top of a feed, because offsets shift and cause duplicates.
No. IntersectionObserver has been available in all major browsers since 2019, and a small hook covers the common case. A data-fetching library with built-in paging helpers is worth it when you already use one for caching requests.
Virtualisation renders only the rows inside the visible window plus a small buffer, positioned inside a spacer the full height of the list. Reach for it when the DOM holds thousands of rows and scrolling or typing starts to lag. @tanstack/react-virtual and its useVirtualizer hook are a common choice.
Search crawlers do not scroll like people do, so content that only appears after scrolling may never be seen. Back the feed with real paginated URLs such as ?page=2 that load on their own, and link to them.
Usually the effect re-creates the observer on every render because the callback changes identity, or the first page is too short to push the sentinel off screen. Keep the latest callback in a ref and make sure each page fills more than the viewport.
Related: Debounce vs throttle, React drag and drop, React table examples, JavaScript async fetch, React performance APIs.
Start for free or see all free tools. We.Inc is free to start, with no card needed. AI building is on paid plans from $20 a month.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.