@usefy/use-infinite-scroll 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,144 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/mirunamu00/usefy/master/assets/logo.png" alt="usefy logo" width="120" />
3
+ </p>
4
+
5
+ <h1 align="center">@usefy/use-infinite-scroll</h1>
6
+
7
+ <p align="center">
8
+ <strong>Sentinel-driven infinite loading built on IntersectionObserver — attach one ref, load more automatically</strong>
9
+ </p>
10
+
11
+ <p align="center">
12
+ <a href="https://www.npmjs.com/package/@usefy/use-infinite-scroll"><img src="https://img.shields.io/npm/v/@usefy/use-infinite-scroll.svg?style=flat-square&color=007acc" alt="npm version" /></a>
13
+ <a href="https://www.npmjs.com/package/@usefy/use-infinite-scroll"><img src="https://img.shields.io/npm/dm/@usefy/use-infinite-scroll.svg?style=flat-square&color=007acc" alt="npm downloads" /></a>
14
+ <a href="https://bundlephobia.com/package/@usefy/use-infinite-scroll"><img src="https://img.shields.io/bundlephobia/minzip/@usefy/use-infinite-scroll?style=flat-square&color=007acc" alt="bundle size" /></a>
15
+ <a href="https://github.com/mirunamu00/usefy/blob/master/LICENSE"><img src="https://img.shields.io/npm/l/@usefy/use-infinite-scroll.svg?style=flat-square&color=007acc" alt="license" /></a>
16
+ </p>
17
+
18
+ <p align="center">
19
+ <a href="#installation">Installation</a> •
20
+ <a href="#quick-start">Quick Start</a> •
21
+ <a href="#api">API</a> •
22
+ <a href="#testing">Testing</a> •
23
+ <a href="#license">License</a>
24
+ </p>
25
+
26
+ <p align="center">
27
+ <a href="https://mirunamu00.github.io/usefy/?path=/docs/hooks-useinfinitescroll--docs" target="_blank" rel="noopener noreferrer">
28
+ <strong>📚 View Storybook Demo</strong>
29
+ </a>
30
+ </p>
31
+
32
+ ---
33
+
34
+ ## Overview
35
+
36
+ `useInfiniteScroll` is part of the [@usefy](https://www.npmjs.com/org/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.
37
+
38
+ Built on top of [`@usefy/use-intersection-observer`](https://www.npmjs.com/package/@usefy/use-intersection-observer), so it inherits its SSR-safe and StrictMode-safe behavior — no scroll listeners, no offset math.
39
+
40
+ ## Features
41
+
42
+ - **One ref, that's it** — `const ref = useInfiniteScroll(loadMore, { hasMore, loading })`; attach `ref` to a sentinel
43
+ - **Fires once per intersection** — it does not re-fire while the sentinel stays in view (the sentinel must leave and re-enter)
44
+ - **Respects `hasMore` / `loading` / `enabled`** — stops observing entirely once exhausted or disabled
45
+ - **No double-fire on async loads** — honours the `loading` flag *and* an internal in-flight guard: if `loadMore` returns a promise, it won't fire again until it settles
46
+ - **Latest-callback pattern** — changing `loadMore` (or the flags) never re-subscribes the observer
47
+ - **Prefetch support** — `rootMargin` fires `loadMore` before the sentinel is on screen; custom `root`, `threshold` supported
48
+ - **SSR-safe & StrictMode-safe** — returns an inert no-op ref on the server; no observer leaks
49
+ - **TypeScript-first** — full type inference and exported types
50
+ - **Tiny & tree-shakeable** — one small dependency, published as its own package
51
+
52
+ ## Installation
53
+
54
+ ```bash
55
+ # npm
56
+ npm install @usefy/use-infinite-scroll
57
+
58
+ # yarn
59
+ yarn add @usefy/use-infinite-scroll
60
+
61
+ # pnpm
62
+ pnpm add @usefy/use-infinite-scroll
63
+ ```
64
+
65
+ Requires React 18 or 19 (`peerDependencies: "react": "^18.0.0 || ^19.0.0"`).
66
+
67
+ ## Quick Start
68
+
69
+ ```tsx
70
+ import { useState } from "react";
71
+ import { useInfiniteScroll } from "@usefy/use-infinite-scroll";
72
+
73
+ function Feed() {
74
+ const [items, setItems] = useState<Item[]>([]);
75
+ const [loading, setLoading] = useState(false);
76
+ const [hasMore, setHasMore] = useState(true);
77
+
78
+ const loadMore = async () => {
79
+ setLoading(true);
80
+ const { data, done } = await fetchNextPage(items.length);
81
+ setItems((prev) => [...prev, ...data]);
82
+ setHasMore(!done);
83
+ setLoading(false);
84
+ };
85
+
86
+ const sentinelRef = useInfiniteScroll(loadMore, { hasMore, loading });
87
+
88
+ return (
89
+ <ul>
90
+ {items.map((item) => (
91
+ <li key={item.id}>{item.title}</li>
92
+ ))}
93
+ {/* Render the sentinel only while there is more to load. */}
94
+ {hasMore && <li ref={sentinelRef} aria-hidden />}
95
+ </ul>
96
+ );
97
+ }
98
+ ```
99
+
100
+ ## API
101
+
102
+ ```ts
103
+ const sentinelRef = useInfiniteScroll(loadMore, options?);
104
+ ```
105
+
106
+ ### Parameters
107
+
108
+ | Parameter | Type | Description |
109
+ | --- | --- | --- |
110
+ | `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. |
111
+ | `options` | `UseInfiniteScrollOptions` | Optional configuration (see below). |
112
+
113
+ ### Options — `UseInfiniteScrollOptions`
114
+
115
+ | Option | Type | Default | Description |
116
+ | --- | --- | --- | --- |
117
+ | `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. |
118
+ | `loading` | `boolean` | `false` | Whether a load is in progress. When `true`, an intersection will not trigger `loadMore`. Wire this to your own loading state. |
119
+ | `enabled` | `boolean` | `true` | Master switch. When `false`, the sentinel is not observed and `loadMore` never fires, regardless of `hasMore`/`loading`. |
120
+ | `rootMargin` | `string` | `"0px"` | CSS margin around the root. A positive value like `"300px"` fires `loadMore` *before* the sentinel is on screen (prefetch). |
121
+ | `threshold` | `number \| number[]` | `0` | Intersection ratio(s) that trigger a load. `0` fires as soon as a single pixel is visible. |
122
+ | `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. |
123
+
124
+ ### Returns — `UseInfiniteScrollRef`
125
+
126
+ 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.
127
+
128
+ ### Behavior notes
129
+
130
+ - **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.
131
+ - **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).
132
+ - **Stops observing when exhausted.** Once `hasMore` is `false` (or `enabled` is `false`), the underlying observer disconnects — no wasted work.
133
+ - **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.
134
+ - **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.
135
+
136
+ ## Testing
137
+
138
+ 📊 <a href="https://mirunamu00.github.io/usefy/coverage/use-infinite-scroll/src/index.html" target="_blank" rel="noopener noreferrer"><strong>View Detailed Coverage Report</strong></a> (GitHub Pages) — **19 tests**, 100% statement coverage.
139
+
140
+ ## License
141
+
142
+ MIT © [mirunamu](https://github.com/mirunamu00)
143
+
144
+ This package is part of the [usefy](https://github.com/mirunamu00/usefy) monorepo.
@@ -0,0 +1,137 @@
1
+ /**
2
+ * The function invoked to load the next page of data.
3
+ *
4
+ * It may be synchronous (`void`) or asynchronous (`Promise<void>`). When it
5
+ * returns a promise, `useInfiniteScroll` tracks it as in-flight and will not
6
+ * fire again until it settles, even if the sentinel re-enters view in the
7
+ * meantime — an internal guard on top of the `loading` flag.
8
+ */
9
+ type LoadMoreFn = () => void | Promise<void>;
10
+ /**
11
+ * Options for configuring the {@link useInfiniteScroll} hook.
12
+ */
13
+ interface UseInfiniteScrollOptions {
14
+ /**
15
+ * Whether there is more data to load. When `false`, the sentinel is no longer
16
+ * observed and `loadMore` will never fire — set it `false` once the last page
17
+ * has been reached.
18
+ * @default true
19
+ */
20
+ hasMore?: boolean;
21
+ /**
22
+ * Whether a load is currently in progress. When `true`, an intersection will
23
+ * not trigger `loadMore`, preventing overlapping requests. Wire this to your
24
+ * own loading state.
25
+ * @default false
26
+ */
27
+ loading?: boolean;
28
+ /**
29
+ * Master enable/disable switch. When `false`, the sentinel is not observed
30
+ * and `loadMore` never fires, regardless of `hasMore`/`loading`.
31
+ * @default true
32
+ */
33
+ enabled?: boolean;
34
+ /**
35
+ * Margin around the root, in CSS margin syntax, used to grow (or shrink) the
36
+ * area that triggers a load. A positive margin like `"200px"` fires
37
+ * `loadMore` *before* the sentinel is actually on screen, prefetching the
38
+ * next page for a seamless scroll.
39
+ * @default "0px"
40
+ */
41
+ rootMargin?: string;
42
+ /**
43
+ * The intersection ratio(s) at which a load is triggered. `0` fires as soon
44
+ * as a single pixel of the sentinel is visible.
45
+ * @default 0
46
+ */
47
+ threshold?: number | number[];
48
+ /**
49
+ * The scroll container used as the observer root. `null` (default) uses the
50
+ * browser viewport. Pass a scrollable element (e.g. `containerRef.current`)
51
+ * to run infinite scroll inside a fixed-height panel.
52
+ * @default null
53
+ */
54
+ root?: Element | Document | null;
55
+ }
56
+ /**
57
+ * The value returned by {@link useInfiniteScroll}: a callback ref to attach to
58
+ * the sentinel element (typically an empty `<div>` at the end of the list).
59
+ *
60
+ * @example
61
+ * ```tsx
62
+ * const sentinelRef = useInfiniteScroll(loadMore, { hasMore, loading });
63
+ * return <div ref={sentinelRef} />;
64
+ * ```
65
+ */
66
+ type UseInfiniteScrollRef = (node: Element | null) => void;
67
+
68
+ /**
69
+ * Sentinel-driven infinite scrolling built on the Intersection Observer API.
70
+ *
71
+ * Attach the returned callback ref to a small sentinel element rendered at the
72
+ * end of your list. Whenever that sentinel scrolls into view — and there is
73
+ * more to load, nothing is currently loading, and the hook is enabled — your
74
+ * `loadMore` callback fires exactly **once** per intersection.
75
+ *
76
+ * Built on top of {@link useIntersectionObserver}, so it inherits its SSR-safe
77
+ * (returns an inert no-op ref on the server) and StrictMode-safe behavior.
78
+ *
79
+ * Key guarantees:
80
+ * - **Fires once per intersection.** It does not re-fire while the sentinel
81
+ * stays in view; the sentinel must leave and re-enter to trigger again.
82
+ * - **Latest-callback pattern.** Changing `loadMore` (or the `hasMore` /
83
+ * `loading` / `enabled` flags) never re-subscribes the observer.
84
+ * - **No double-fire while loading.** Respects the `loading` flag *and* an
85
+ * internal in-flight guard: if `loadMore` returns a promise, the hook will
86
+ * not fire again until it settles.
87
+ * - **Stops observing when exhausted.** Once `hasMore` is `false` (or `enabled`
88
+ * is `false`), the observer disconnects entirely.
89
+ *
90
+ * @param loadMore - Called to load the next page. May be sync or async.
91
+ * @param options - Configuration (`hasMore`, `loading`, `enabled`, `rootMargin`,
92
+ * `threshold`, `root`).
93
+ * @returns A callback ref to attach to the sentinel element.
94
+ *
95
+ * @example
96
+ * ```tsx
97
+ * function Feed() {
98
+ * const [items, setItems] = useState<Item[]>([]);
99
+ * const [loading, setLoading] = useState(false);
100
+ * const [hasMore, setHasMore] = useState(true);
101
+ *
102
+ * const loadMore = async () => {
103
+ * setLoading(true);
104
+ * const { data, done } = await fetchNextPage(items.length);
105
+ * setItems((prev) => [...prev, ...data]);
106
+ * setHasMore(!done);
107
+ * setLoading(false);
108
+ * };
109
+ *
110
+ * const sentinelRef = useInfiniteScroll(loadMore, { hasMore, loading });
111
+ *
112
+ * return (
113
+ * <ul>
114
+ * {items.map((item) => (
115
+ * <li key={item.id}>{item.title}</li>
116
+ * ))}
117
+ * {hasMore && <li ref={sentinelRef} aria-hidden />}
118
+ * </ul>
119
+ * );
120
+ * }
121
+ * ```
122
+ *
123
+ * @example
124
+ * ```tsx
125
+ * // Prefetch 300px early, inside a fixed-height scroll container.
126
+ * const containerRef = useRef<HTMLDivElement>(null);
127
+ * const sentinelRef = useInfiniteScroll(loadMore, {
128
+ * hasMore,
129
+ * loading,
130
+ * root: containerRef.current,
131
+ * rootMargin: "300px",
132
+ * });
133
+ * ```
134
+ */
135
+ declare function useInfiniteScroll(loadMore: LoadMoreFn, options?: UseInfiniteScrollOptions): UseInfiniteScrollRef;
136
+
137
+ export { type LoadMoreFn, type UseInfiniteScrollOptions, type UseInfiniteScrollRef, useInfiniteScroll };
@@ -0,0 +1,137 @@
1
+ /**
2
+ * The function invoked to load the next page of data.
3
+ *
4
+ * It may be synchronous (`void`) or asynchronous (`Promise<void>`). When it
5
+ * returns a promise, `useInfiniteScroll` tracks it as in-flight and will not
6
+ * fire again until it settles, even if the sentinel re-enters view in the
7
+ * meantime — an internal guard on top of the `loading` flag.
8
+ */
9
+ type LoadMoreFn = () => void | Promise<void>;
10
+ /**
11
+ * Options for configuring the {@link useInfiniteScroll} hook.
12
+ */
13
+ interface UseInfiniteScrollOptions {
14
+ /**
15
+ * Whether there is more data to load. When `false`, the sentinel is no longer
16
+ * observed and `loadMore` will never fire — set it `false` once the last page
17
+ * has been reached.
18
+ * @default true
19
+ */
20
+ hasMore?: boolean;
21
+ /**
22
+ * Whether a load is currently in progress. When `true`, an intersection will
23
+ * not trigger `loadMore`, preventing overlapping requests. Wire this to your
24
+ * own loading state.
25
+ * @default false
26
+ */
27
+ loading?: boolean;
28
+ /**
29
+ * Master enable/disable switch. When `false`, the sentinel is not observed
30
+ * and `loadMore` never fires, regardless of `hasMore`/`loading`.
31
+ * @default true
32
+ */
33
+ enabled?: boolean;
34
+ /**
35
+ * Margin around the root, in CSS margin syntax, used to grow (or shrink) the
36
+ * area that triggers a load. A positive margin like `"200px"` fires
37
+ * `loadMore` *before* the sentinel is actually on screen, prefetching the
38
+ * next page for a seamless scroll.
39
+ * @default "0px"
40
+ */
41
+ rootMargin?: string;
42
+ /**
43
+ * The intersection ratio(s) at which a load is triggered. `0` fires as soon
44
+ * as a single pixel of the sentinel is visible.
45
+ * @default 0
46
+ */
47
+ threshold?: number | number[];
48
+ /**
49
+ * The scroll container used as the observer root. `null` (default) uses the
50
+ * browser viewport. Pass a scrollable element (e.g. `containerRef.current`)
51
+ * to run infinite scroll inside a fixed-height panel.
52
+ * @default null
53
+ */
54
+ root?: Element | Document | null;
55
+ }
56
+ /**
57
+ * The value returned by {@link useInfiniteScroll}: a callback ref to attach to
58
+ * the sentinel element (typically an empty `<div>` at the end of the list).
59
+ *
60
+ * @example
61
+ * ```tsx
62
+ * const sentinelRef = useInfiniteScroll(loadMore, { hasMore, loading });
63
+ * return <div ref={sentinelRef} />;
64
+ * ```
65
+ */
66
+ type UseInfiniteScrollRef = (node: Element | null) => void;
67
+
68
+ /**
69
+ * Sentinel-driven infinite scrolling built on the Intersection Observer API.
70
+ *
71
+ * Attach the returned callback ref to a small sentinel element rendered at the
72
+ * end of your list. Whenever that sentinel scrolls into view — and there is
73
+ * more to load, nothing is currently loading, and the hook is enabled — your
74
+ * `loadMore` callback fires exactly **once** per intersection.
75
+ *
76
+ * Built on top of {@link useIntersectionObserver}, so it inherits its SSR-safe
77
+ * (returns an inert no-op ref on the server) and StrictMode-safe behavior.
78
+ *
79
+ * Key guarantees:
80
+ * - **Fires once per intersection.** It does not re-fire while the sentinel
81
+ * stays in view; the sentinel must leave and re-enter to trigger again.
82
+ * - **Latest-callback pattern.** Changing `loadMore` (or the `hasMore` /
83
+ * `loading` / `enabled` flags) never re-subscribes the observer.
84
+ * - **No double-fire while loading.** Respects the `loading` flag *and* an
85
+ * internal in-flight guard: if `loadMore` returns a promise, the hook will
86
+ * not fire again until it settles.
87
+ * - **Stops observing when exhausted.** Once `hasMore` is `false` (or `enabled`
88
+ * is `false`), the observer disconnects entirely.
89
+ *
90
+ * @param loadMore - Called to load the next page. May be sync or async.
91
+ * @param options - Configuration (`hasMore`, `loading`, `enabled`, `rootMargin`,
92
+ * `threshold`, `root`).
93
+ * @returns A callback ref to attach to the sentinel element.
94
+ *
95
+ * @example
96
+ * ```tsx
97
+ * function Feed() {
98
+ * const [items, setItems] = useState<Item[]>([]);
99
+ * const [loading, setLoading] = useState(false);
100
+ * const [hasMore, setHasMore] = useState(true);
101
+ *
102
+ * const loadMore = async () => {
103
+ * setLoading(true);
104
+ * const { data, done } = await fetchNextPage(items.length);
105
+ * setItems((prev) => [...prev, ...data]);
106
+ * setHasMore(!done);
107
+ * setLoading(false);
108
+ * };
109
+ *
110
+ * const sentinelRef = useInfiniteScroll(loadMore, { hasMore, loading });
111
+ *
112
+ * return (
113
+ * <ul>
114
+ * {items.map((item) => (
115
+ * <li key={item.id}>{item.title}</li>
116
+ * ))}
117
+ * {hasMore && <li ref={sentinelRef} aria-hidden />}
118
+ * </ul>
119
+ * );
120
+ * }
121
+ * ```
122
+ *
123
+ * @example
124
+ * ```tsx
125
+ * // Prefetch 300px early, inside a fixed-height scroll container.
126
+ * const containerRef = useRef<HTMLDivElement>(null);
127
+ * const sentinelRef = useInfiniteScroll(loadMore, {
128
+ * hasMore,
129
+ * loading,
130
+ * root: containerRef.current,
131
+ * rootMargin: "300px",
132
+ * });
133
+ * ```
134
+ */
135
+ declare function useInfiniteScroll(loadMore: LoadMoreFn, options?: UseInfiniteScrollOptions): UseInfiniteScrollRef;
136
+
137
+ export { type LoadMoreFn, type UseInfiniteScrollOptions, type UseInfiniteScrollRef, useInfiniteScroll };
package/dist/index.js ADDED
@@ -0,0 +1,94 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ useInfiniteScroll: () => useInfiniteScroll
24
+ });
25
+ module.exports = __toCommonJS(index_exports);
26
+
27
+ // src/useInfiniteScroll.ts
28
+ var import_react = require("react");
29
+ var import_use_intersection_observer = require("@usefy/use-intersection-observer");
30
+ function useInfiniteScroll(loadMore, options = {}) {
31
+ const {
32
+ hasMore = true,
33
+ loading = false,
34
+ enabled = true,
35
+ rootMargin = "0px",
36
+ threshold = 0,
37
+ root = null
38
+ } = options;
39
+ const loadMoreRef = (0, import_react.useRef)(loadMore);
40
+ loadMoreRef.current = loadMore;
41
+ const stateRef = (0, import_react.useRef)({ hasMore, loading, enabled });
42
+ stateRef.current = { hasMore, loading, enabled };
43
+ const inFlightRef = (0, import_react.useRef)(false);
44
+ const prevInViewRef = (0, import_react.useRef)(false);
45
+ const fireLoadMore = (0, import_react.useCallback)(() => {
46
+ const state = stateRef.current;
47
+ if (!state.enabled || !state.hasMore || state.loading || inFlightRef.current) {
48
+ return;
49
+ }
50
+ inFlightRef.current = true;
51
+ let result;
52
+ try {
53
+ result = loadMoreRef.current();
54
+ } catch (error) {
55
+ inFlightRef.current = false;
56
+ throw error;
57
+ }
58
+ if (result && typeof result.then === "function") {
59
+ const release = () => {
60
+ inFlightRef.current = false;
61
+ };
62
+ Promise.resolve(result).then(release, release);
63
+ } else {
64
+ inFlightRef.current = false;
65
+ }
66
+ }, []);
67
+ const handleChange = (0, import_react.useCallback)(
68
+ (_entry, inView) => {
69
+ if (inView && !prevInViewRef.current) {
70
+ fireLoadMore();
71
+ }
72
+ prevInViewRef.current = inView;
73
+ },
74
+ [fireLoadMore]
75
+ );
76
+ const { ref } = (0, import_use_intersection_observer.useIntersectionObserver)({
77
+ root,
78
+ rootMargin,
79
+ threshold,
80
+ // Disconnect the observer entirely when there is nothing more to load or the
81
+ // hook is disabled — there is no point observing a sentinel we will never
82
+ // act on. `loading` is intentionally *not* folded in here: we keep observing
83
+ // while a load is in flight (the fire guard handles it) so we don't lose the
84
+ // sentinel's position mid-request.
85
+ enabled: enabled && hasMore,
86
+ onChange: handleChange
87
+ });
88
+ return ref;
89
+ }
90
+ // Annotate the CommonJS export names for ESM import in node:
91
+ 0 && (module.exports = {
92
+ useInfiniteScroll
93
+ });
94
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/useInfiniteScroll.ts"],"sourcesContent":["export { useInfiniteScroll } from \"./useInfiniteScroll\";\nexport type {\n LoadMoreFn,\n UseInfiniteScrollOptions,\n UseInfiniteScrollRef,\n} from \"./types\";\n","import { useCallback, useRef } from \"react\";\nimport {\n useIntersectionObserver,\n type OnChangeCallback,\n} from \"@usefy/use-intersection-observer\";\nimport type {\n LoadMoreFn,\n UseInfiniteScrollOptions,\n UseInfiniteScrollRef,\n} from \"./types\";\n\n/**\n * Sentinel-driven infinite scrolling built on the Intersection Observer API.\n *\n * Attach the returned callback ref to a small sentinel element rendered at the\n * end of your list. Whenever that sentinel scrolls into view — and there is\n * more to load, nothing is currently loading, and the hook is enabled — your\n * `loadMore` callback fires exactly **once** per intersection.\n *\n * Built on top of {@link useIntersectionObserver}, so it inherits its SSR-safe\n * (returns an inert no-op ref on the server) and StrictMode-safe behavior.\n *\n * Key guarantees:\n * - **Fires once per intersection.** It does not re-fire while the sentinel\n * stays in view; the sentinel must leave and re-enter to trigger again.\n * - **Latest-callback pattern.** Changing `loadMore` (or the `hasMore` /\n * `loading` / `enabled` flags) never re-subscribes the observer.\n * - **No double-fire while loading.** Respects the `loading` flag *and* an\n * internal in-flight guard: if `loadMore` returns a promise, the hook will\n * not fire again until it settles.\n * - **Stops observing when exhausted.** Once `hasMore` is `false` (or `enabled`\n * is `false`), the observer disconnects entirely.\n *\n * @param loadMore - Called to load the next page. May be sync or async.\n * @param options - Configuration (`hasMore`, `loading`, `enabled`, `rootMargin`,\n * `threshold`, `root`).\n * @returns A callback ref to attach to the sentinel element.\n *\n * @example\n * ```tsx\n * function Feed() {\n * const [items, setItems] = useState<Item[]>([]);\n * const [loading, setLoading] = useState(false);\n * const [hasMore, setHasMore] = useState(true);\n *\n * const loadMore = async () => {\n * setLoading(true);\n * const { data, done } = await fetchNextPage(items.length);\n * setItems((prev) => [...prev, ...data]);\n * setHasMore(!done);\n * setLoading(false);\n * };\n *\n * const sentinelRef = useInfiniteScroll(loadMore, { hasMore, loading });\n *\n * return (\n * <ul>\n * {items.map((item) => (\n * <li key={item.id}>{item.title}</li>\n * ))}\n * {hasMore && <li ref={sentinelRef} aria-hidden />}\n * </ul>\n * );\n * }\n * ```\n *\n * @example\n * ```tsx\n * // Prefetch 300px early, inside a fixed-height scroll container.\n * const containerRef = useRef<HTMLDivElement>(null);\n * const sentinelRef = useInfiniteScroll(loadMore, {\n * hasMore,\n * loading,\n * root: containerRef.current,\n * rootMargin: \"300px\",\n * });\n * ```\n */\nexport function useInfiniteScroll(\n loadMore: LoadMoreFn,\n options: UseInfiniteScrollOptions = {}\n): UseInfiniteScrollRef {\n const {\n hasMore = true,\n loading = false,\n enabled = true,\n rootMargin = \"0px\",\n threshold = 0,\n root = null,\n } = options;\n\n // Latest-callback pattern: mirror the callback and flags into refs so that a\n // changing `loadMore` (or flags) never re-subscribes the observer.\n const loadMoreRef = useRef<LoadMoreFn>(loadMore);\n loadMoreRef.current = loadMore;\n\n const stateRef = useRef({ hasMore, loading, enabled });\n stateRef.current = { hasMore, loading, enabled };\n\n // Internal in-flight guard for the async case, independent of the `loading`\n // prop: prevents a second fire while a returned promise is still pending.\n const inFlightRef = useRef(false);\n\n // Tracks the sentinel's previous visibility so we fire on the `false -> true`\n // transition only. Without this, a multi-threshold observer (e.g.\n // `threshold: [0, 0.5, 1]`) emits several `inView === true` callbacks as the\n // sentinel scrolls in; a synchronous `loadMore` (one that never sets\n // `loading`) would otherwise fire once per crossing.\n const prevInViewRef = useRef(false);\n\n const fireLoadMore = useCallback(() => {\n const state = stateRef.current;\n if (!state.enabled || !state.hasMore || state.loading || inFlightRef.current) {\n return;\n }\n\n inFlightRef.current = true;\n\n let result: void | Promise<void>;\n try {\n result = loadMoreRef.current();\n } catch (error) {\n // Synchronous throw: release the guard so a later intersection can retry,\n // then rethrow (mirrors the caller invoking loadMore directly).\n inFlightRef.current = false;\n throw error;\n }\n\n if (result && typeof (result as Promise<void>).then === \"function\") {\n const release = () => {\n inFlightRef.current = false;\n };\n // Handle both settle paths so the guard is always released and a rejected\n // promise does not surface as an unhandled rejection. The hook does not\n // itself surface loadMore errors — handle them inside loadMore.\n Promise.resolve(result).then(release, release);\n } else {\n inFlightRef.current = false;\n }\n }, []);\n\n const handleChange = useCallback<OnChangeCallback>(\n (_entry, inView) => {\n // Fire only on the transition into view, so a multi-threshold observer\n // that emits several `inView === true` callbacks per entry triggers a\n // single load.\n if (inView && !prevInViewRef.current) {\n fireLoadMore();\n }\n prevInViewRef.current = inView;\n },\n [fireLoadMore]\n );\n\n const { ref } = useIntersectionObserver({\n root,\n rootMargin,\n threshold,\n // Disconnect the observer entirely when there is nothing more to load or the\n // hook is disabled — there is no point observing a sentinel we will never\n // act on. `loading` is intentionally *not* folded in here: we keep observing\n // while a load is in flight (the fire guard handles it) so we don't lose the\n // sentinel's position mid-request.\n enabled: enabled && hasMore,\n onChange: handleChange,\n });\n\n return ref;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACAA,mBAAoC;AACpC,uCAGO;AA0EA,SAAS,kBACd,UACA,UAAoC,CAAC,GACf;AACtB,QAAM;AAAA,IACJ,UAAU;AAAA,IACV,UAAU;AAAA,IACV,UAAU;AAAA,IACV,aAAa;AAAA,IACb,YAAY;AAAA,IACZ,OAAO;AAAA,EACT,IAAI;AAIJ,QAAM,kBAAc,qBAAmB,QAAQ;AAC/C,cAAY,UAAU;AAEtB,QAAM,eAAW,qBAAO,EAAE,SAAS,SAAS,QAAQ,CAAC;AACrD,WAAS,UAAU,EAAE,SAAS,SAAS,QAAQ;AAI/C,QAAM,kBAAc,qBAAO,KAAK;AAOhC,QAAM,oBAAgB,qBAAO,KAAK;AAElC,QAAM,mBAAe,0BAAY,MAAM;AACrC,UAAM,QAAQ,SAAS;AACvB,QAAI,CAAC,MAAM,WAAW,CAAC,MAAM,WAAW,MAAM,WAAW,YAAY,SAAS;AAC5E;AAAA,IACF;AAEA,gBAAY,UAAU;AAEtB,QAAI;AACJ,QAAI;AACF,eAAS,YAAY,QAAQ;AAAA,IAC/B,SAAS,OAAO;AAGd,kBAAY,UAAU;AACtB,YAAM;AAAA,IACR;AAEA,QAAI,UAAU,OAAQ,OAAyB,SAAS,YAAY;AAClE,YAAM,UAAU,MAAM;AACpB,oBAAY,UAAU;AAAA,MACxB;AAIA,cAAQ,QAAQ,MAAM,EAAE,KAAK,SAAS,OAAO;AAAA,IAC/C,OAAO;AACL,kBAAY,UAAU;AAAA,IACxB;AAAA,EACF,GAAG,CAAC,CAAC;AAEL,QAAM,mBAAe;AAAA,IACnB,CAAC,QAAQ,WAAW;AAIlB,UAAI,UAAU,CAAC,cAAc,SAAS;AACpC,qBAAa;AAAA,MACf;AACA,oBAAc,UAAU;AAAA,IAC1B;AAAA,IACA,CAAC,YAAY;AAAA,EACf;AAEA,QAAM,EAAE,IAAI,QAAI,0DAAwB;AAAA,IACtC;AAAA,IACA;AAAA,IACA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAMA,SAAS,WAAW;AAAA,IACpB,UAAU;AAAA,EACZ,CAAC;AAED,SAAO;AACT;","names":[]}
package/dist/index.mjs ADDED
@@ -0,0 +1,69 @@
1
+ // src/useInfiniteScroll.ts
2
+ import { useCallback, useRef } from "react";
3
+ import {
4
+ useIntersectionObserver
5
+ } from "@usefy/use-intersection-observer";
6
+ function useInfiniteScroll(loadMore, options = {}) {
7
+ const {
8
+ hasMore = true,
9
+ loading = false,
10
+ enabled = true,
11
+ rootMargin = "0px",
12
+ threshold = 0,
13
+ root = null
14
+ } = options;
15
+ const loadMoreRef = useRef(loadMore);
16
+ loadMoreRef.current = loadMore;
17
+ const stateRef = useRef({ hasMore, loading, enabled });
18
+ stateRef.current = { hasMore, loading, enabled };
19
+ const inFlightRef = useRef(false);
20
+ const prevInViewRef = useRef(false);
21
+ const fireLoadMore = useCallback(() => {
22
+ const state = stateRef.current;
23
+ if (!state.enabled || !state.hasMore || state.loading || inFlightRef.current) {
24
+ return;
25
+ }
26
+ inFlightRef.current = true;
27
+ let result;
28
+ try {
29
+ result = loadMoreRef.current();
30
+ } catch (error) {
31
+ inFlightRef.current = false;
32
+ throw error;
33
+ }
34
+ if (result && typeof result.then === "function") {
35
+ const release = () => {
36
+ inFlightRef.current = false;
37
+ };
38
+ Promise.resolve(result).then(release, release);
39
+ } else {
40
+ inFlightRef.current = false;
41
+ }
42
+ }, []);
43
+ const handleChange = useCallback(
44
+ (_entry, inView) => {
45
+ if (inView && !prevInViewRef.current) {
46
+ fireLoadMore();
47
+ }
48
+ prevInViewRef.current = inView;
49
+ },
50
+ [fireLoadMore]
51
+ );
52
+ const { ref } = useIntersectionObserver({
53
+ root,
54
+ rootMargin,
55
+ threshold,
56
+ // Disconnect the observer entirely when there is nothing more to load or the
57
+ // hook is disabled — there is no point observing a sentinel we will never
58
+ // act on. `loading` is intentionally *not* folded in here: we keep observing
59
+ // while a load is in flight (the fire guard handles it) so we don't lose the
60
+ // sentinel's position mid-request.
61
+ enabled: enabled && hasMore,
62
+ onChange: handleChange
63
+ });
64
+ return ref;
65
+ }
66
+ export {
67
+ useInfiniteScroll
68
+ };
69
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/useInfiniteScroll.ts"],"sourcesContent":["import { useCallback, useRef } from \"react\";\nimport {\n useIntersectionObserver,\n type OnChangeCallback,\n} from \"@usefy/use-intersection-observer\";\nimport type {\n LoadMoreFn,\n UseInfiniteScrollOptions,\n UseInfiniteScrollRef,\n} from \"./types\";\n\n/**\n * Sentinel-driven infinite scrolling built on the Intersection Observer API.\n *\n * Attach the returned callback ref to a small sentinel element rendered at the\n * end of your list. Whenever that sentinel scrolls into view — and there is\n * more to load, nothing is currently loading, and the hook is enabled — your\n * `loadMore` callback fires exactly **once** per intersection.\n *\n * Built on top of {@link useIntersectionObserver}, so it inherits its SSR-safe\n * (returns an inert no-op ref on the server) and StrictMode-safe behavior.\n *\n * Key guarantees:\n * - **Fires once per intersection.** It does not re-fire while the sentinel\n * stays in view; the sentinel must leave and re-enter to trigger again.\n * - **Latest-callback pattern.** Changing `loadMore` (or the `hasMore` /\n * `loading` / `enabled` flags) never re-subscribes the observer.\n * - **No double-fire while loading.** Respects the `loading` flag *and* an\n * internal in-flight guard: if `loadMore` returns a promise, the hook will\n * not fire again until it settles.\n * - **Stops observing when exhausted.** Once `hasMore` is `false` (or `enabled`\n * is `false`), the observer disconnects entirely.\n *\n * @param loadMore - Called to load the next page. May be sync or async.\n * @param options - Configuration (`hasMore`, `loading`, `enabled`, `rootMargin`,\n * `threshold`, `root`).\n * @returns A callback ref to attach to the sentinel element.\n *\n * @example\n * ```tsx\n * function Feed() {\n * const [items, setItems] = useState<Item[]>([]);\n * const [loading, setLoading] = useState(false);\n * const [hasMore, setHasMore] = useState(true);\n *\n * const loadMore = async () => {\n * setLoading(true);\n * const { data, done } = await fetchNextPage(items.length);\n * setItems((prev) => [...prev, ...data]);\n * setHasMore(!done);\n * setLoading(false);\n * };\n *\n * const sentinelRef = useInfiniteScroll(loadMore, { hasMore, loading });\n *\n * return (\n * <ul>\n * {items.map((item) => (\n * <li key={item.id}>{item.title}</li>\n * ))}\n * {hasMore && <li ref={sentinelRef} aria-hidden />}\n * </ul>\n * );\n * }\n * ```\n *\n * @example\n * ```tsx\n * // Prefetch 300px early, inside a fixed-height scroll container.\n * const containerRef = useRef<HTMLDivElement>(null);\n * const sentinelRef = useInfiniteScroll(loadMore, {\n * hasMore,\n * loading,\n * root: containerRef.current,\n * rootMargin: \"300px\",\n * });\n * ```\n */\nexport function useInfiniteScroll(\n loadMore: LoadMoreFn,\n options: UseInfiniteScrollOptions = {}\n): UseInfiniteScrollRef {\n const {\n hasMore = true,\n loading = false,\n enabled = true,\n rootMargin = \"0px\",\n threshold = 0,\n root = null,\n } = options;\n\n // Latest-callback pattern: mirror the callback and flags into refs so that a\n // changing `loadMore` (or flags) never re-subscribes the observer.\n const loadMoreRef = useRef<LoadMoreFn>(loadMore);\n loadMoreRef.current = loadMore;\n\n const stateRef = useRef({ hasMore, loading, enabled });\n stateRef.current = { hasMore, loading, enabled };\n\n // Internal in-flight guard for the async case, independent of the `loading`\n // prop: prevents a second fire while a returned promise is still pending.\n const inFlightRef = useRef(false);\n\n // Tracks the sentinel's previous visibility so we fire on the `false -> true`\n // transition only. Without this, a multi-threshold observer (e.g.\n // `threshold: [0, 0.5, 1]`) emits several `inView === true` callbacks as the\n // sentinel scrolls in; a synchronous `loadMore` (one that never sets\n // `loading`) would otherwise fire once per crossing.\n const prevInViewRef = useRef(false);\n\n const fireLoadMore = useCallback(() => {\n const state = stateRef.current;\n if (!state.enabled || !state.hasMore || state.loading || inFlightRef.current) {\n return;\n }\n\n inFlightRef.current = true;\n\n let result: void | Promise<void>;\n try {\n result = loadMoreRef.current();\n } catch (error) {\n // Synchronous throw: release the guard so a later intersection can retry,\n // then rethrow (mirrors the caller invoking loadMore directly).\n inFlightRef.current = false;\n throw error;\n }\n\n if (result && typeof (result as Promise<void>).then === \"function\") {\n const release = () => {\n inFlightRef.current = false;\n };\n // Handle both settle paths so the guard is always released and a rejected\n // promise does not surface as an unhandled rejection. The hook does not\n // itself surface loadMore errors — handle them inside loadMore.\n Promise.resolve(result).then(release, release);\n } else {\n inFlightRef.current = false;\n }\n }, []);\n\n const handleChange = useCallback<OnChangeCallback>(\n (_entry, inView) => {\n // Fire only on the transition into view, so a multi-threshold observer\n // that emits several `inView === true` callbacks per entry triggers a\n // single load.\n if (inView && !prevInViewRef.current) {\n fireLoadMore();\n }\n prevInViewRef.current = inView;\n },\n [fireLoadMore]\n );\n\n const { ref } = useIntersectionObserver({\n root,\n rootMargin,\n threshold,\n // Disconnect the observer entirely when there is nothing more to load or the\n // hook is disabled — there is no point observing a sentinel we will never\n // act on. `loading` is intentionally *not* folded in here: we keep observing\n // while a load is in flight (the fire guard handles it) so we don't lose the\n // sentinel's position mid-request.\n enabled: enabled && hasMore,\n onChange: handleChange,\n });\n\n return ref;\n}\n"],"mappings":";AAAA,SAAS,aAAa,cAAc;AACpC;AAAA,EACE;AAAA,OAEK;AA0EA,SAAS,kBACd,UACA,UAAoC,CAAC,GACf;AACtB,QAAM;AAAA,IACJ,UAAU;AAAA,IACV,UAAU;AAAA,IACV,UAAU;AAAA,IACV,aAAa;AAAA,IACb,YAAY;AAAA,IACZ,OAAO;AAAA,EACT,IAAI;AAIJ,QAAM,cAAc,OAAmB,QAAQ;AAC/C,cAAY,UAAU;AAEtB,QAAM,WAAW,OAAO,EAAE,SAAS,SAAS,QAAQ,CAAC;AACrD,WAAS,UAAU,EAAE,SAAS,SAAS,QAAQ;AAI/C,QAAM,cAAc,OAAO,KAAK;AAOhC,QAAM,gBAAgB,OAAO,KAAK;AAElC,QAAM,eAAe,YAAY,MAAM;AACrC,UAAM,QAAQ,SAAS;AACvB,QAAI,CAAC,MAAM,WAAW,CAAC,MAAM,WAAW,MAAM,WAAW,YAAY,SAAS;AAC5E;AAAA,IACF;AAEA,gBAAY,UAAU;AAEtB,QAAI;AACJ,QAAI;AACF,eAAS,YAAY,QAAQ;AAAA,IAC/B,SAAS,OAAO;AAGd,kBAAY,UAAU;AACtB,YAAM;AAAA,IACR;AAEA,QAAI,UAAU,OAAQ,OAAyB,SAAS,YAAY;AAClE,YAAM,UAAU,MAAM;AACpB,oBAAY,UAAU;AAAA,MACxB;AAIA,cAAQ,QAAQ,MAAM,EAAE,KAAK,SAAS,OAAO;AAAA,IAC/C,OAAO;AACL,kBAAY,UAAU;AAAA,IACxB;AAAA,EACF,GAAG,CAAC,CAAC;AAEL,QAAM,eAAe;AAAA,IACnB,CAAC,QAAQ,WAAW;AAIlB,UAAI,UAAU,CAAC,cAAc,SAAS;AACpC,qBAAa;AAAA,MACf;AACA,oBAAc,UAAU;AAAA,IAC1B;AAAA,IACA,CAAC,YAAY;AAAA,EACf;AAEA,QAAM,EAAE,IAAI,IAAI,wBAAwB;AAAA,IACtC;AAAA,IACA;AAAA,IACA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAMA,SAAS,WAAW;AAAA,IACpB,UAAU;AAAA,EACZ,CAAC;AAED,SAAO;AACT;","names":[]}
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@usefy/use-infinite-scroll",
3
+ "version": "0.20.0",
4
+ "description": "A React hook for sentinel-driven infinite scrolling built on IntersectionObserver",
5
+ "main": "./dist/index.js",
6
+ "module": "./dist/index.mjs",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.mjs",
12
+ "require": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "sideEffects": false,
19
+ "dependencies": {
20
+ "@usefy/use-intersection-observer": "0.20.0"
21
+ },
22
+ "peerDependencies": {
23
+ "react": "^18.0.0 || ^19.0.0"
24
+ },
25
+ "devDependencies": {
26
+ "@testing-library/jest-dom": "^6.9.1",
27
+ "@testing-library/react": "^16.3.1",
28
+ "@testing-library/user-event": "^14.6.1",
29
+ "@types/react": "^19.0.0",
30
+ "jsdom": "^27.3.0",
31
+ "react": "^19.0.0",
32
+ "rimraf": "^6.0.1",
33
+ "tsup": "^8.0.0",
34
+ "typescript": "^5.0.0",
35
+ "vitest": "^4.0.16"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "https://github.com/mirunamu00/usefy.git",
43
+ "directory": "packages/hooks/use-infinite-scroll"
44
+ },
45
+ "license": "MIT",
46
+ "keywords": [
47
+ "react",
48
+ "hooks",
49
+ "infinite-scroll",
50
+ "infinite-loading",
51
+ "intersection-observer",
52
+ "sentinel",
53
+ "load-more",
54
+ "pagination",
55
+ "lazy-loading",
56
+ "useInfiniteScroll"
57
+ ],
58
+ "scripts": {
59
+ "build": "tsup",
60
+ "dev": "tsup --watch",
61
+ "test": "vitest run",
62
+ "test:watch": "vitest",
63
+ "typecheck": "tsc --noEmit",
64
+ "clean": "rimraf dist"
65
+ }
66
+ }