Skip to content
usefy

useInfiniteScroll

Layout & observers

Sentinel-driven infinite loading built on IntersectionObserver — attach one ref, load more automatically

Install

$ npm install @usefy/use-infinite-scroll

Quick 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

ParameterTypeDescription
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.
optionsUseInfiniteScrollOptionsOptional configuration (see below).

Options — UseInfiniteScrollOptions

OptionTypeDefaultDescription
hasMorebooleantrueWhether there is more to load. When false, the sentinel is no longer observed and loadMore never fires. Set it false after the last page.
loadingbooleanfalseWhether a load is in progress. When true, an intersection will not trigger loadMore. Wire this to your own loading state.
enabledbooleantrueMaster switch. When false, the sentinel is not observed and loadMore never fires, regardless of hasMore/loading.
rootMarginstring"0px"CSS margin around the root. A positive value like "300px" fires loadMore before the sentinel is on screen (prefetch).
thresholdnumber | number[]0Intersection ratio(s) that trigger a load. 0 fires as soon as a single pixel is visible.
rootElement | Document | nullnullThe 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. loadMore fires 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 loading prop 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 surface loadMore errors — handle them inside loadMore (e.g. try/catch and reset your loading state).
  • Stops observing when exhausted. Once hasMore is false (or enabled is false), the underlying observer disconnects — no wasted work.
  • SSR / StrictMode. On the server (or where IntersectionObserver is 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. Changing loadMore and the hasMore / loading / enabled flags never re-subscribes the observer, but threshold and root are observer configuration — passing a fresh inline array (threshold={[0, 0.5]}) or element every render re-subscribes it. Hoist them to a constant or useMemo/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.

More in layout & observers