useInfiniteScroll Hook
Load the next page when a sentinel enters the viewport. IntersectionObserver, hasMore, in-flight guards, and the footer that never intersects.
- react
- hooks
- intersectionobserver
- interview
A window scroll listener that reads getBoundingClientRect on every frame is the old way to load “one more page.” It janks, it misses nested overflow, and it double-fetches when the user sits at the bottom. useInfiniteScroll observes a sentinel node with IntersectionObserver and calls onLoadMore when that node enters the root.
The hook does not own the list. It owns the trigger. hasMore, in-flight, abort, and item identity stay in the data layer (or in the machine-coding Infinite Scroll Feed you would build around this).
The interview failure mode is a footer that never intersects: wrong root, a zero-height sentinel, or an observer attached while ref.current was still null.
- Observe a sentinel, not
windowscroll. Disconnect on cleanup.- Guard with
hasMoreand an in-flight flag. After a load, if the sentinel is still visible,IntersectionObserverwill not fire again until it leaves and re-enters. Check once after the fetch, or keep loading until it leaves.- The scrollable ancestor is
root.root: nullmeans the viewport. A list insideoverflow: autowill never intersect the viewport the way you think.- Attach after the sentinel exists. A ref that is
nullon the first effect run is how you subscribe to nothing.- Pair loads with AbortController when the query changes. The observer hook is not the fetch hook.
Observe a sentinel, not a scroll listener on window
import { useEffect, useRef, useState } from 'react';
function useInfiniteScroll({
hasMore,
onLoadMore,
root = null,
rootMargin = '0px',
threshold = 0,
enabled = true,
}) {
const [sentinel, setSentinel] = useState(null);
const loadingRef = useRef(false);
const onLoadMoreRef = useRef(onLoadMore);
useEffect(() => {
onLoadMoreRef.current = onLoadMore;
}, [onLoadMore]);
useEffect(() => {
if (!enabled || !hasMore || !sentinel) return undefined;
const observer = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (!entry?.isIntersecting) return;
if (loadingRef.current) return;
loadingRef.current = true;
Promise.resolve(onLoadMoreRef.current()).finally(() => {
loadingRef.current = false;
});
},
{ root, rootMargin, threshold }
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [sentinel, hasMore, enabled, root, rootMargin, threshold]);
return setSentinel;
}
function Feed({ items, hasMore, loadMore }) {
const setSentinel = useInfiniteScroll({ hasMore, onLoadMore: loadMore });
return (
<ul>
{items.map((item) => (
<li key={item.id}>{item.title}</li>
))}
{hasMore ? (
<li ref={setSentinel} aria-hidden="true" />
) : (
<li>End of feed</li>
)}
</ul>
);
}
Callback ref (setSentinel) instead of useRef is deliberate. useRef does not re-render when .current is filled. The observe effect would run once with null and never attach. useState for the node re-runs the effect when the sentinel mounts or unmounts.
IntersectionObserver delivers a batch of IntersectionObserverEntry objects. isIntersecting is the boolean you want. rootMargin: '200px 0px' preloads before the user hits the true bottom.
Why not window scroll
useEffect(() => {
function onScroll() {
const nearBottom =
window.innerHeight + window.scrollY >= document.body.offsetHeight - 200;
if (nearBottom) loadMore();
}
window.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, [loadMore]);
Problems:
- Layout reads on a hot path.
- The feed is often inside a panel, not the window.
- No
hasMore/ in-flight in this sketch, so you spamloadMore. - You still need cleanup, and you still miss unobserve-on-idle.
The observer is async and batched. That is the point of the API.
In-flight and hasMore
| Guard | What it prevents |
|---|---|
hasMore === false |
Observing a sentinel that should not exist; extra pages after the last cursor |
loadingRef |
Re-entry while the current page is in flight and the sentinel stays visible |
enabled |
Pause while a filter request resets the list |
A short first page leaves the sentinel in view. The callback fires once when it enters. After you append, if it never left the root, the observer stays silent. That is the stuck-footer bug: one page loaded, more data exists, the user has nothing to scroll. After onLoadMore resolves, read entry.isIntersecting (or getBoundingClientRect) and load again while hasMore and the sentinel is still visible. loadingRef still matters so overlapping fetches from a reconnect do not stack. finally clears the flag even when onLoadMore throws.
onLoadMoreRef keeps the observer identity stable when the parent passes an inline function. Same pattern as useInterval: do not reconnect the subscription because the callback’s identity changed.
The footer that never intersects
Work this list when the next page never loads:
- Wrong root. The list scrolls inside
<div style={{ overflow: 'auto', height: 400 }}>. You passedroot: null(viewport). The sentinel is always “on screen” in the document, or never crosses the viewport threshold the way you expect. Pass the overflow node asroot. - Sentinel not in the scrolling box. Footer outside the overflow container cannot intersect that root.
- Zero-size node with
threshold: 1. A0×0<li>may never reach 100% intersection. Usethreshold: 0and give the sentinel a height (even 1px) orrootMargin. - Observer attached too early.
ref.currentwasnull. Use a callback ref / state. hasMorefalse from a bad first parse (nextCursortreated as missing).display: noneorvisibilitytricks on a collapsed tab. Observers do not fire fordisplay: nonetargets in a useful way; re-observe when the tab is shown.rootis a React ref object. The observer wants anElement, not{ current }. PassrootRef.currentvia state the same way as the sentinel.
Data race on the fetch side
The observer can request page 3 while page 2 is still parsing if you clear loadingRef too early. Tie the flag to the page promise, not to “I called loadMore.” Abort and cursor handling belong in loadMore / useFeed, not in the observer:
async function loadMore() {
if (!cursor) return;
const page = await fetchPage(cursor, { signal });
setItems((rows) => rows.concat(page.items));
setCursor(page.nextCursor);
}
When the search query changes, abort, reset items, reset cursor. The sentinel will intersect again for page 1. Cancelable Async Tasks is the language-level version of that race.
Footguns
Duplicate keys. If page 2 repeats an id from page 1, React reuse will look like “infinite scroll skipped a card.” Dedupe by id when appending.
Virtualization. A windowing list unmounts off-screen rows. The sentinel must sit outside the virtualized range (a pinned footer) or the observer has nothing to watch. See List Virtualization.
Accessibility. Keyboard users tab through the list; do not make “load more” mouse-only. A visible button that calls the same onLoadMore is the fallback when intersection is unavailable or reduced-motion users prefer explicit paging.
Disconnect vs unobserve. One sentinel: disconnect() in cleanup is fine. Many images: unobserve(img) per target.
Interview out-loud answer
I put a sentinel at the end of the list and observe it with
IntersectionObserver, not a window scroll handler. The callback loads the next page only whenisIntersecting,hasMoreis true, and a load is not already in flight. I store the node in state so I do not observenull. Cleanup callsdisconnect. If the list scrolls inside a div, that div isroot. A footer that never fires is usually the wrong root, a zero-height sentinel, or an effect that ran before the ref attached.
Further reading
Related on this site
- IntersectionObserver
- Infinite Scroll Feed
- Rules of Hooks
- useEffect Fundamentals
- Custom Hooks Design
- useFetch With AbortController
- Cancelable Async Tasks
- List Virtualization
Related guides
- useCopyToClipboard HookCopy text with navigator.clipboard, fallback execCommand, transient copied state, and permissions that fail in insecure contexts.
- useDebouncedValue HookDebounce a changing value in React without firing effects on every keystroke. Cleanup, leading vs trailing, and the stale timer that updates unmounted state.
- useEventListener HookAttach a window, document, or element listener with a stable handler ref so you do not rebind on every render. Capture option and target changes.
- useIdle HookDetect idle users: reset a timer on input events, fire idle after timeout, and decide whether mount starts the timer immediately.
- useInterval HookA setInterval that sees the latest callback without restarting the timer when the callback identity changes. Dan Abramov’s ref pattern, named.