diff --git a/src/__tests__/useInfiniteScroll.test.tsx b/src/__tests__/useInfiniteScroll.test.tsx
new file mode 100644
index 0000000..5391ef8
--- /dev/null
+++ b/src/__tests__/useInfiniteScroll.test.tsx
@@ -0,0 +1,177 @@
+import { RefObject } from 'react';
+import { render, cleanup, act } from '@testing-library/react';
+import { useInfiniteScroll, UseInfiniteScrollOptions } from '../index';
+import { MockIntersectionObserver } from './setup/intersectionObserverMock';
+
+function HookWrapper(props: UseInfiniteScrollOptions) {
+ const { sentinelRef, isLoading } = useInfiniteScroll(props);
+ return (
+
+
}
+ data-testid="sentinel"
+ />
+ {isLoading &&
}
+
+ );
+}
+
+describe('useInfiniteScroll hook', () => {
+ beforeEach(() => {
+ MockIntersectionObserver.instances = [];
+ });
+
+ afterEach(cleanup);
+
+ it('calls next when sentinel intersects', () => {
+ const next = jest.fn();
+ render(
);
+
+ act(() => {
+ MockIntersectionObserver.instances[0].triggerIntersect();
+ });
+
+ expect(next).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not create an observer when hasMore is false', () => {
+ render(
);
+ expect(MockIntersectionObserver.instances).toHaveLength(0);
+ });
+
+ it('does not call next twice before dataLength changes (load guard)', () => {
+ const next = jest.fn();
+ render(
);
+
+ const observer = MockIntersectionObserver.instances[0];
+ act(() => {
+ observer.triggerIntersect();
+ observer.triggerIntersect();
+ });
+
+ expect(next).toHaveBeenCalledTimes(1);
+ });
+
+ it('isLoading is true after sentinel fires and false after dataLength changes', () => {
+ const next = jest.fn();
+ const { getByTestId, queryByTestId, rerender } = render(
+
+ );
+
+ expect(queryByTestId('loader')).toBeNull();
+
+ act(() => {
+ MockIntersectionObserver.instances[0].triggerIntersect();
+ });
+
+ expect(getByTestId('loader')).toBeTruthy();
+
+ rerender(
);
+
+ expect(queryByTestId('loader')).toBeNull();
+ });
+
+ it('resets load guard after dataLength changes so next can fire again', () => {
+ const next = jest.fn();
+ const { rerender } = render(
+
+ );
+
+ act(() => {
+ MockIntersectionObserver.instances[0].triggerIntersect();
+ });
+ expect(next).toHaveBeenCalledTimes(1);
+
+ rerender(
);
+
+ act(() => {
+ MockIntersectionObserver.instances[0].triggerIntersect();
+ });
+ expect(next).toHaveBeenCalledTimes(2);
+ });
+
+ it('uses scrollableTarget string id as the IO root', () => {
+ const target = document.createElement('div');
+ target.id = 'hookScroll';
+ document.body.appendChild(target);
+
+ render(
+
+ );
+
+ expect(MockIntersectionObserver.instances[0].options.root).toBe(target);
+ document.body.removeChild(target);
+ });
+
+ it('accepts an HTMLElement directly as scrollableTarget', () => {
+ const target = document.createElement('div');
+ document.body.appendChild(target);
+
+ render(
+
+ );
+
+ expect(MockIntersectionObserver.instances[0].options.root).toBe(target);
+ document.body.removeChild(target);
+ });
+
+ it('applies top rootMargin in inverse mode', () => {
+ render(
+
+ );
+
+ const { rootMargin } = MockIntersectionObserver.instances[0].options;
+ expect(rootMargin).toBe('20% 0px 0px 0px');
+ });
+
+ it('applies bottom rootMargin in normal (non-inverse) mode', () => {
+ render(
+
+ );
+
+ const { rootMargin } = MockIntersectionObserver.instances[0].options;
+ expect(rootMargin).toBe('0px 0px 20% 0px');
+ });
+
+ it('does not create an observer when sentinel ref is not attached', () => {
+ function NoRefWrapper(props: UseInfiniteScrollOptions) {
+ useInfiniteScroll(props);
+ return
;
+ }
+ render(
);
+ expect(MockIntersectionObserver.instances).toHaveLength(0);
+ });
+
+ it('does not throw when IntersectionObserver is unavailable (SSR)', () => {
+ const g = globalThis as Record
;
+ const original = g['IntersectionObserver'];
+ delete g['IntersectionObserver'];
+
+ expect(() => {
+ render();
+ }).not.toThrow();
+
+ g['IntersectionObserver'] = original;
+ });
+});
diff --git a/src/index.tsx b/src/index.tsx
index e7a6cf1..14a122b 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -8,28 +8,114 @@ import {
} from 'react';
import { buildRootMargin } from './utils/buildRootMargin';
+export { useInfiniteScroll } from './useInfiniteScroll';
+export type {
+ UseInfiniteScrollOptions,
+ UseInfiniteScrollResult,
+} from './useInfiniteScroll';
+
type Fn = () => any;
export interface Props {
+ /**
+ * Total number of items currently rendered. Unlocks the next load when it
+ * changes. Always pass the length of your full accumulated list, not just
+ * the most recently fetched page.
+ */
+ dataLength: number;
+ /**
+ * Called when the user scrolls near the end of the list. Must append new
+ * items to your list state (not replace them). The component calls this at
+ * most once per data load, guarded by an IntersectionObserver sentinel.
+ */
next: Fn;
+ /**
+ * Whether more data exists to load. When false, the observer stops and
+ * `endMessage` is shown instead of `loader`.
+ */
hasMore: boolean;
+ /**
+ * The full accumulated list of items to render. Pass every item loaded so
+ * far — the component is not paginated internally.
+ */
children: ReactNode;
+ /**
+ * Element shown while the next page is being fetched (while `hasMore` is
+ * true and `next` has been triggered).
+ */
loader: ReactNode;
+ /**
+ * How close to the end of the list before `next` fires.
+ * - Number 0–1: fraction of container height, e.g. `0.8` triggers at 80%
+ * scrolled (default).
+ * - Pixel string: absolute offset, e.g. `"200px"` triggers 200 px before
+ * the end.
+ * @default 0.8
+ */
scrollThreshold?: number | string;
+ /** Shown below the list once `hasMore` is false. */
endMessage?: ReactNode;
+ /** Inline styles applied to the inner scroll container. */
style?: CSSProperties;
+ /**
+ * Fixed height for the scroll container. When provided, a scrollable box
+ * of this height is rendered. Omit to scroll the window (document body).
+ */
height?: number | string;
+ /**
+ * A scrollable parent element that already provides overflow scrollbars.
+ * Accepts a DOM element reference or the element's string `id`. Pass this
+ * instead of `height` when the scroll container is owned by the parent.
+ *
+ * @example
+ * // string id
+ * scrollableTarget="scrollableDiv"
+ *
+ * @example
+ * // ref value
+ * const ref = useRef(null);
+ *
+ */
scrollableTarget?: HTMLElement | string | null;
+ /**
+ * Set to `true` when `children` is not a plain array (e.g. a single node
+ * or a fragment). Prevents the component from treating a length of 0 as
+ * "no items loaded".
+ */
hasChildren?: boolean;
+ /**
+ * Reverse the scroll direction: the sentinel is placed at the top of the
+ * list and `next` loads older content upward. Use with
+ * `flexDirection: 'column-reverse'` on the scroll container for chat or
+ * messaging UIs.
+ * @default false
+ */
inverse?: boolean;
+ /**
+ * Enable pull-down-to-refresh on touch and mouse. Requires
+ * `refreshFunction` to be provided.
+ * @default false
+ */
pullDownToRefresh?: boolean;
+ /** Content shown while the user is pulling down. @default Pull down to refresh
*/
pullDownToRefreshContent?: ReactNode;
+ /** Content shown when the pull threshold is breached. @default Release to refresh
*/
releaseToRefreshContent?: ReactNode;
+ /**
+ * Minimum pixels the user must pull before `refreshFunction` fires.
+ * @default 100
+ */
pullDownToRefreshThreshold?: number;
+ /**
+ * Called when the pull-to-refresh threshold is breached. Should reload or
+ * reset the list to fresh data.
+ */
refreshFunction?: Fn;
+ /** Called on every scroll event of the scroll container. */
onScroll?: (e: UIEvent) => any;
- dataLength: number;
+ /** Scroll Y position (in pixels) to restore when the component mounts. */
initialScrollY?: number;
+ /** CSS class name added to the inner scroll container element. */
className?: string;
}
diff --git a/src/stories/UseInfiniteScrollHook.tsx b/src/stories/UseInfiniteScrollHook.tsx
new file mode 100644
index 0000000..d3605f9
--- /dev/null
+++ b/src/stories/UseInfiniteScrollHook.tsx
@@ -0,0 +1,72 @@
+import React, { useState } from 'react';
+import { useInfiniteScroll } from '../index';
+
+const itemStyle = {
+ height: 30,
+ border: '1px solid steelblue',
+ margin: 6,
+ padding: 8,
+ borderRadius: 4,
+};
+
+const containerStyle: React.CSSProperties = {
+ height: 400,
+ overflow: 'auto',
+ border: '2px solid steelblue',
+ borderRadius: 4,
+};
+
+export default function UseInfiniteScrollHook() {
+ const [items, setItems] = useState(() =>
+ Array.from({ length: 20 }, (_, i) => i)
+ );
+ const [hasMore, setHasMore] = useState(true);
+
+ const { sentinelRef, isLoading } = useInfiniteScroll({
+ next: () => {
+ setTimeout(() => {
+ setItems((prev) => {
+ const next = Array.from({ length: 20 }, (_, i) => prev.length + i);
+ if (prev.length + next.length >= 200) setHasMore(false);
+ return [...prev, ...next];
+ });
+ }, 800);
+ },
+ hasMore,
+ dataLength: items.length,
+ });
+
+ return (
+
+
useInfiniteScroll hook, custom UI
+
+ Fully custom markup. The hook manages the observer; you own the layout.
+
+
+
+
+ );
+}
diff --git a/src/stories/stories.tsx b/src/stories/stories.tsx
index 53c5b07..f9e455c 100644
--- a/src/stories/stories.tsx
+++ b/src/stories/stories.tsx
@@ -4,7 +4,8 @@ import WindowInf from './WindowInfiniteScrollComponent';
import PullDownToRefreshInfScroll from './PullDownToRefreshInfScroll';
import InfiniteScrollWithHeight from './InfiniteScrollWithHeight';
import ScrollableTargetInfiniteScroll from './ScrollableTargetInfScroll';
-import ScrollableTop from './ScrollableTop';
+import ScrolleableTop from './ScrolleableTop';
+import UseInfiniteScrollHook from './UseInfiniteScrollHook';
const meta: Meta = {
title: 'Components',
@@ -38,3 +39,8 @@ export const InfiniteScrollTop: Story = {
name: 'InfiniteScrollTop',
render: () => ,
};
+
+export const UseInfiniteScrollHookStory: Story = {
+ name: 'useInfiniteScroll (hook)',
+ render: () => ,
+};
diff --git a/src/useInfiniteScroll.ts b/src/useInfiniteScroll.ts
new file mode 100644
index 0000000..7070998
--- /dev/null
+++ b/src/useInfiniteScroll.ts
@@ -0,0 +1,157 @@
+import { useEffect, useRef, useState, useCallback, RefObject } from 'react';
+import { buildRootMargin } from './utils/buildRootMargin';
+
+export interface UseInfiniteScrollOptions {
+ /**
+ * Called when the sentinel enters the viewport. Append new items to your
+ * list state, do not replace the existing items.
+ */
+ next: () => void;
+ /**
+ * Whether more data exists to load. Set to false when you have fetched all
+ * pages, the observer disconnects and stops calling next().
+ */
+ hasMore: boolean;
+ /**
+ * Total number of items currently rendered. Resets the load guard so the
+ * next page can be triggered after new items arrive. Pass the length of
+ * your full accumulated list, not just the current page.
+ */
+ dataLength: number;
+ /**
+ * How close to the sentinel before next() fires.
+ * - Number 0–1: fraction of container height, e.g. 0.8 triggers at 80%.
+ * - Pixel string: absolute offset, e.g. "200px" triggers 200 px before end.
+ * @default 0.8
+ */
+ scrollThreshold?: number | string;
+ /**
+ * A scrollable parent element (or its DOM id string) to use as the
+ * IntersectionObserver root. Defaults to the viewport when omitted.
+ */
+ scrollableTarget?: HTMLElement | string | null;
+ /**
+ * Reverse scroll direction, sentinel is observed from the top. Use for
+ * chat or messaging UIs with flex-direction: column-reverse.
+ * @default false
+ */
+ inverse?: boolean;
+}
+
+export interface UseInfiniteScrollResult {
+ /**
+ * Attach this ref to a div at the bottom of your list (or top for inverse
+ * mode). When it enters the viewport the hook calls next().
+ *
+ * @example
+ *
+ * {items.map(item => - {item.name}
)}
+ *
+ *
+ */
+ sentinelRef: RefObject;
+ /**
+ * True from when the sentinel fires until dataLength changes (i.e. new
+ * data has arrived). Use this to show your own loading indicator.
+ */
+ isLoading: boolean;
+}
+
+/**
+ * Low-level hook for building custom infinite scroll UIs.
+ *
+ * Manages an IntersectionObserver that watches a sentinel element you place
+ * at the end of your list. When the sentinel enters the viewport, next() is
+ * called. The hook handles deduplication and resets automatically when
+ * dataLength changes.
+ *
+ * Use the InfiniteScroll component instead if you want a ready-made wrapper
+ * with built-in loader, endMessage, pull-to-refresh, and inverse scroll UI.
+ *
+ * @example Basic usage
+ * ```tsx
+ * function Feed() {
+ * const [items, setItems] = useState(initialItems);
+ * const [hasMore, setHasMore] = useState(true);
+ *
+ * const { sentinelRef, isLoading } = useInfiniteScroll({
+ * next: async () => {
+ * const more = await fetchItems(items.length);
+ * if (more.length === 0) { setHasMore(false); return; }
+ * setItems(prev => [...prev, ...more]);
+ * },
+ * hasMore,
+ * dataLength: items.length,
+ * });
+ *
+ * return (
+ *
+ * {items.map(item => - {item.name}
)}
+ *
+ * {isLoading && - Loading...
}
+ *
+ * );
+ * }
+ * ```
+ */
+export function useInfiniteScroll({
+ next,
+ hasMore,
+ dataLength,
+ scrollThreshold = 0.8,
+ scrollableTarget,
+ inverse = false,
+}: UseInfiniteScrollOptions): UseInfiniteScrollResult {
+ const [isLoading, setIsLoading] = useState(false);
+ const sentinelRef = useRef(null);
+ const actionTriggeredRef = useRef(false);
+
+ // Stable ref so the observer callback always calls the latest next()
+ // without triggering observer reconnection when an inline function is passed.
+ const nextRef = useRef(next);
+ nextRef.current = next;
+
+ const getScrollableNode = useCallback((): HTMLElement | null => {
+ if (scrollableTarget instanceof HTMLElement) return scrollableTarget;
+ if (typeof scrollableTarget === 'string') {
+ return document.getElementById(scrollableTarget);
+ }
+ return null;
+ }, [scrollableTarget]);
+
+ // Reset the load guard when new data arrives.
+ useEffect(() => {
+ actionTriggeredRef.current = false;
+ setIsLoading(false);
+ }, [dataLength]);
+
+ // IntersectionObserver lifecycle.
+ useEffect(() => {
+ if (!hasMore) return;
+ if (typeof IntersectionObserver === 'undefined') return;
+
+ const sentinel = sentinelRef.current;
+ if (!sentinel) return;
+
+ const root: Element | null = getScrollableNode();
+
+ const observer = new IntersectionObserver(
+ ([entry]) => {
+ if (!entry.isIntersecting || actionTriggeredRef.current) return;
+ actionTriggeredRef.current = true;
+ setIsLoading(true);
+ nextRef.current();
+ },
+ {
+ root,
+ rootMargin: buildRootMargin(scrollThreshold, inverse),
+ threshold: 0,
+ }
+ );
+
+ observer.observe(sentinel);
+ return () => observer.disconnect();
+ }, [hasMore, scrollThreshold, inverse, getScrollableNode]);
+
+ return { sentinelRef, isLoading };
+}