useInfiniteScroll
Layout & observersSentinel-driven infinite loading built on IntersectionObserver — attach one ref, load more automatically
Install
$ npm install @usefy/use-infinite-scrollQuick start
useInfiniteScroll.tsx
import { useState } from "react";
import { useInfiniteScroll } from "@usefy/use-infinite-scroll";
function Feed() {
const [items, setItems] = useState<Item[]>([]);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const loadMore = async () => {
setLoading(true);
const { data, done } = await fetchNextPage(items.length);
setItems((prev) => [...prev, ...data]);
setHasMore(!done);
setLoading(false);
};
const sentinelRef = useInfiniteScroll(loadMore, { hasMore, loading });
return (
<ul>
{items.map((item) => (
<li key={item.id}>{item.title}</li>
))}
{/* Render the sentinel only while there is more to load. */}
{hasMore && <li ref={sentinelRef} aria-hidden />}
</ul>
);
}API reference
const sentinelRef = useInfiniteScroll(loadMore, options?);
Parameters
| Parameter | Type | Description |
|---|---|---|
loadMore | () => void | Promise<void> | Called to load the next page when the sentinel enters view. May be sync or async — when it returns a promise, the hook treats the load as in-flight and won't fire again until it settles. Changing this reference never re-subscribes the observer. |
options | UseInfiniteScrollOptions | Optional configuration (see below). |
Options — UseInfiniteScrollOptions
| Option | Type | Default | Description |
|---|---|---|---|
hasMore | boolean | true | Whether there is more to load. When false, the sentinel is no longer observed and loadMore never fires. Set it false after the last page. |
loading | boolean | false | Whether a load is in progress. When true, an intersection will not trigger loadMore. Wire this to your own loading state. |
enabled | boolean | true | Master switch. When false, the sentinel is not observed and loadMore never fires, regardless of hasMore/loading. |
rootMargin | string | "0px" | CSS margin around the root. A positive value like "300px" fires loadMore before the sentinel is on screen (prefetch). |
threshold | number | number[] | 0 | Intersection ratio(s) that trigger a load. 0 fires as soon as a single pixel is visible. |
root | Element | Document | null | null | The scroll container used as the observer root. null uses the browser viewport; pass a scrollable element to run infinite scroll inside a fixed-height panel. |
Returns — UseInfiniteScrollRef
A callback ref — (node: Element | null) => void — to attach to your sentinel element. It has a stable identity across renders, so it is safe to pass directly to a ref prop.
Behavior notes
- Once per intersection.
loadMorefires when the sentinel enters view; it does not re-fire while the sentinel stays visible. If a load doesn't fill the viewport and the sentinel is still visible, the user scrolls (or the sentinel re-enters) to trigger the next page — this matches native infinite-scroll UX and avoids runaway loops. - No double-fire. Two guards prevent overlapping loads: the
loadingprop you control, and an internal in-flight guard for the async case (a second intersection while the returned promise is pending is ignored). The hook does not surfaceloadMoreerrors — handle them insideloadMore(e.g.try/catchand reset yourloadingstate). - Stops observing when exhausted. Once
hasMoreisfalse(orenabledisfalse), the underlying observer disconnects — no wasted work. - SSR / StrictMode. On the server (or where
IntersectionObserveris unavailable) the returned ref is an inert no-op and nothing fires. Under StrictMode's double-mount the observer is set up and torn down cleanly. - Memoize
threshold/root. ChangingloadMoreand thehasMore/loading/enabledflags never re-subscribes the observer, butthresholdandrootare observer configuration — passing a fresh inline array (threshold={[0, 0.5]}) or element every render re-subscribes it. Hoist them to a constant oruseMemo/ref if they are non-primitive.
Go deeper
This page is the quick reference. For every example, prop, and edge case, read the full README — or open Storybook to change props live.