@usefy/use-infinite-scroll
v0.25.1
Published
A React hook for sentinel-driven infinite scrolling built on IntersectionObserver
Maintainers
Readme
Overview
useInfiniteScroll is part of the @usefy ecosystem — a collection of production-ready, TypeScript-first, SSR-safe React hooks. It turns "load the next page when the user reaches the bottom" into a one-liner: render a small sentinel element at the end of your list, attach the returned ref, and your loadMore callback fires whenever that sentinel scrolls into view — once per intersection, never while a load is already in flight.
Built on top of @usefy/use-intersection-observer, so it inherits its SSR-safe and StrictMode-safe behavior — no scroll listeners, no offset math.
Features
- One ref, that's it —
const ref = useInfiniteScroll(loadMore, { hasMore, loading }); attachrefto a sentinel - Fires once per intersection — it does not re-fire while the sentinel stays in view (the sentinel must leave and re-enter)
- Respects
hasMore/loading/enabled— stops observing entirely once exhausted or disabled - No double-fire on async loads — honours the
loadingflag and an internal in-flight guard: ifloadMorereturns a promise, it won't fire again until it settles - Latest-callback pattern — changing
loadMore(or the flags) never re-subscribes the observer - Prefetch support —
rootMarginfiresloadMorebefore the sentinel is on screen; customroot,thresholdsupported - SSR-safe & StrictMode-safe — returns an inert no-op ref on the server; no observer leaks
- TypeScript-first — full type inference and exported types
- Tiny & tree-shakeable — one small dependency, published as its own package
Installation
# npm
npm install @usefy/use-infinite-scroll
# yarn
yarn add @usefy/use-infinite-scroll
# pnpm
pnpm add @usefy/use-infinite-scrollRequires React 18 or 19 (peerDependencies: "react": "^18.0.0 || ^19.0.0").
Quick Start
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
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.
Testing
📊 View Detailed Coverage Report (GitHub Pages) — 19 tests, 100% statement coverage.
License
MIT © mirunamu
This package is part of the usefy monorepo.
