From 18f0a233acbd73ce01936d6a774720e82e551c52 Mon Sep 17 00:00:00 2001 From: iamdarshshah Date: Mon, 27 Apr 2026 19:59:46 +0530 Subject: [PATCH 1/3] feat: add useInfiniteScroll hook and Storybook story --- src/index.tsx | 88 ++++++++++++++- src/stories/UseInfiniteScrollHook.tsx | 72 ++++++++++++ src/stories/stories.tsx | 6 + src/useInfiniteScroll.ts | 157 ++++++++++++++++++++++++++ 4 files changed, 322 insertions(+), 1 deletion(-) create mode 100644 src/stories/UseInfiniteScrollHook.tsx create mode 100644 src/useInfiniteScroll.ts 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. +

+
+
+
    + {items.map((n) => ( +
  • + item #{n + 1} +
  • + ))} +
  • + Loading more items... +
  • + )} + {!hasMore && ( +
  • + All items loaded. +
  • + )} +
+
+
+ ); +} diff --git a/src/stories/stories.tsx b/src/stories/stories.tsx index 1756581..264cb35 100644 --- a/src/stories/stories.tsx +++ b/src/stories/stories.tsx @@ -5,6 +5,7 @@ import PullDownToRefreshInfScroll from './PullDownToRefreshInfScroll'; import InfiniteScrollWithHeight from './InfiniteScrollWithHeight'; import ScrollableTargetInfiniteScroll from './ScrollableTargetInfScroll'; 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 }; +} From 8401116004d15398fc6391771b82bdd1177620f8 Mon Sep 17 00:00:00 2001 From: iamdarshshah Date: Mon, 27 Apr 2026 20:08:56 +0530 Subject: [PATCH 2/3] test: add tests for useInfiniteScroll hook --- src/__tests__/useInfiniteScroll.test.tsx | 168 +++++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 src/__tests__/useInfiniteScroll.test.tsx diff --git a/src/__tests__/useInfiniteScroll.test.tsx b/src/__tests__/useInfiniteScroll.test.tsx new file mode 100644 index 0000000..1a04396 --- /dev/null +++ b/src/__tests__/useInfiniteScroll.test.tsx @@ -0,0 +1,168 @@ +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 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; + }); +}); From 9975ecfe99471c87c2b5f84416e7cfb1329d455e Mon Sep 17 00:00:00 2001 From: iamdarshshah Date: Mon, 27 Apr 2026 20:13:30 +0530 Subject: [PATCH 3/3] test: cover null sentinel guard in useInfiniteScroll --- src/__tests__/useInfiniteScroll.test.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/__tests__/useInfiniteScroll.test.tsx b/src/__tests__/useInfiniteScroll.test.tsx index 1a04396..5391ef8 100644 --- a/src/__tests__/useInfiniteScroll.test.tsx +++ b/src/__tests__/useInfiniteScroll.test.tsx @@ -154,6 +154,15 @@ describe('useInfiniteScroll hook', () => { 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'];