Skip to content

Commit 18f0a23

Browse files
committed
feat: add useInfiniteScroll hook and Storybook story
1 parent 810f915 commit 18f0a23

4 files changed

Lines changed: 322 additions & 1 deletion

File tree

src/index.tsx

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,28 +8,114 @@ import {
88
} from 'react';
99
import { buildRootMargin } from './utils/buildRootMargin';
1010

11+
export { useInfiniteScroll } from './useInfiniteScroll';
12+
export type {
13+
UseInfiniteScrollOptions,
14+
UseInfiniteScrollResult,
15+
} from './useInfiniteScroll';
16+
1117
type Fn = () => any;
1218

1319
export interface Props {
20+
/**
21+
* Total number of items currently rendered. Unlocks the next load when it
22+
* changes. Always pass the length of your full accumulated list, not just
23+
* the most recently fetched page.
24+
*/
25+
dataLength: number;
26+
/**
27+
* Called when the user scrolls near the end of the list. Must append new
28+
* items to your list state (not replace them). The component calls this at
29+
* most once per data load, guarded by an IntersectionObserver sentinel.
30+
*/
1431
next: Fn;
32+
/**
33+
* Whether more data exists to load. When false, the observer stops and
34+
* `endMessage` is shown instead of `loader`.
35+
*/
1536
hasMore: boolean;
37+
/**
38+
* The full accumulated list of items to render. Pass every item loaded so
39+
* far — the component is not paginated internally.
40+
*/
1641
children: ReactNode;
42+
/**
43+
* Element shown while the next page is being fetched (while `hasMore` is
44+
* true and `next` has been triggered).
45+
*/
1746
loader: ReactNode;
47+
/**
48+
* How close to the end of the list before `next` fires.
49+
* - Number 0–1: fraction of container height, e.g. `0.8` triggers at 80%
50+
* scrolled (default).
51+
* - Pixel string: absolute offset, e.g. `"200px"` triggers 200 px before
52+
* the end.
53+
* @default 0.8
54+
*/
1855
scrollThreshold?: number | string;
56+
/** Shown below the list once `hasMore` is false. */
1957
endMessage?: ReactNode;
58+
/** Inline styles applied to the inner scroll container. */
2059
style?: CSSProperties;
60+
/**
61+
* Fixed height for the scroll container. When provided, a scrollable box
62+
* of this height is rendered. Omit to scroll the window (document body).
63+
*/
2164
height?: number | string;
65+
/**
66+
* A scrollable parent element that already provides overflow scrollbars.
67+
* Accepts a DOM element reference or the element's string `id`. Pass this
68+
* instead of `height` when the scroll container is owned by the parent.
69+
*
70+
* @example
71+
* // string id
72+
* scrollableTarget="scrollableDiv"
73+
*
74+
* @example
75+
* // ref value
76+
* const ref = useRef(null);
77+
* <div ref={ref}><InfiniteScroll scrollableTarget={ref.current} /></div>
78+
*/
2279
scrollableTarget?: HTMLElement | string | null;
80+
/**
81+
* Set to `true` when `children` is not a plain array (e.g. a single node
82+
* or a fragment). Prevents the component from treating a length of 0 as
83+
* "no items loaded".
84+
*/
2385
hasChildren?: boolean;
86+
/**
87+
* Reverse the scroll direction: the sentinel is placed at the top of the
88+
* list and `next` loads older content upward. Use with
89+
* `flexDirection: 'column-reverse'` on the scroll container for chat or
90+
* messaging UIs.
91+
* @default false
92+
*/
2493
inverse?: boolean;
94+
/**
95+
* Enable pull-down-to-refresh on touch and mouse. Requires
96+
* `refreshFunction` to be provided.
97+
* @default false
98+
*/
2599
pullDownToRefresh?: boolean;
100+
/** Content shown while the user is pulling down. @default <h3>Pull down to refresh</h3> */
26101
pullDownToRefreshContent?: ReactNode;
102+
/** Content shown when the pull threshold is breached. @default <h3>Release to refresh</h3> */
27103
releaseToRefreshContent?: ReactNode;
104+
/**
105+
* Minimum pixels the user must pull before `refreshFunction` fires.
106+
* @default 100
107+
*/
28108
pullDownToRefreshThreshold?: number;
109+
/**
110+
* Called when the pull-to-refresh threshold is breached. Should reload or
111+
* reset the list to fresh data.
112+
*/
29113
refreshFunction?: Fn;
114+
/** Called on every scroll event of the scroll container. */
30115
onScroll?: (e: UIEvent) => any;
31-
dataLength: number;
116+
/** Scroll Y position (in pixels) to restore when the component mounts. */
32117
initialScrollY?: number;
118+
/** CSS class name added to the inner scroll container element. */
33119
className?: string;
34120
}
35121

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import React, { useState } from 'react';
2+
import { useInfiniteScroll } from '../index';
3+
4+
const itemStyle = {
5+
height: 30,
6+
border: '1px solid steelblue',
7+
margin: 6,
8+
padding: 8,
9+
borderRadius: 4,
10+
};
11+
12+
const containerStyle: React.CSSProperties = {
13+
height: 400,
14+
overflow: 'auto',
15+
border: '2px solid steelblue',
16+
borderRadius: 4,
17+
};
18+
19+
export default function UseInfiniteScrollHook() {
20+
const [items, setItems] = useState<number[]>(() =>
21+
Array.from({ length: 20 }, (_, i) => i)
22+
);
23+
const [hasMore, setHasMore] = useState(true);
24+
25+
const { sentinelRef, isLoading } = useInfiniteScroll({
26+
next: () => {
27+
setTimeout(() => {
28+
setItems((prev) => {
29+
const next = Array.from({ length: 20 }, (_, i) => prev.length + i);
30+
if (prev.length + next.length >= 200) setHasMore(false);
31+
return [...prev, ...next];
32+
});
33+
}, 800);
34+
},
35+
hasMore,
36+
dataLength: items.length,
37+
});
38+
39+
return (
40+
<div>
41+
<h1>useInfiniteScroll hook, custom UI</h1>
42+
<p style={{ color: '#666', fontSize: 13 }}>
43+
Fully custom markup. The hook manages the observer; you own the layout.
44+
</p>
45+
<hr />
46+
<div id="hookScrollTarget" style={containerStyle}>
47+
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
48+
{items.map((n) => (
49+
<li key={n} style={itemStyle}>
50+
item #{n + 1}
51+
</li>
52+
))}
53+
<li
54+
ref={sentinelRef}
55+
aria-hidden="true"
56+
style={{ height: 1, padding: 0 }}
57+
/>
58+
{isLoading && (
59+
<li style={{ textAlign: 'center', padding: 12, color: '#888' }}>
60+
Loading more items...
61+
</li>
62+
)}
63+
{!hasMore && (
64+
<li style={{ textAlign: 'center', padding: 12, color: '#aaa' }}>
65+
All items loaded.
66+
</li>
67+
)}
68+
</ul>
69+
</div>
70+
</div>
71+
);
72+
}

src/stories/stories.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import PullDownToRefreshInfScroll from './PullDownToRefreshInfScroll';
55
import InfiniteScrollWithHeight from './InfiniteScrollWithHeight';
66
import ScrollableTargetInfiniteScroll from './ScrollableTargetInfScroll';
77
import ScrolleableTop from './ScrolleableTop';
8+
import UseInfiniteScrollHook from './UseInfiniteScrollHook';
89

910
const meta: Meta = {
1011
title: 'Components',
@@ -38,3 +39,8 @@ export const InfiniteScrollTop: Story = {
3839
name: 'InfiniteScrollTop',
3940
render: () => <ScrolleableTop />,
4041
};
42+
43+
export const UseInfiniteScrollHookStory: Story = {
44+
name: 'useInfiniteScroll (hook)',
45+
render: () => <UseInfiniteScrollHook />,
46+
};

src/useInfiniteScroll.ts

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { useEffect, useRef, useState, useCallback, RefObject } from 'react';
2+
import { buildRootMargin } from './utils/buildRootMargin';
3+
4+
export interface UseInfiniteScrollOptions {
5+
/**
6+
* Called when the sentinel enters the viewport. Append new items to your
7+
* list state, do not replace the existing items.
8+
*/
9+
next: () => void;
10+
/**
11+
* Whether more data exists to load. Set to false when you have fetched all
12+
* pages, the observer disconnects and stops calling next().
13+
*/
14+
hasMore: boolean;
15+
/**
16+
* Total number of items currently rendered. Resets the load guard so the
17+
* next page can be triggered after new items arrive. Pass the length of
18+
* your full accumulated list, not just the current page.
19+
*/
20+
dataLength: number;
21+
/**
22+
* How close to the sentinel before next() fires.
23+
* - Number 0–1: fraction of container height, e.g. 0.8 triggers at 80%.
24+
* - Pixel string: absolute offset, e.g. "200px" triggers 200 px before end.
25+
* @default 0.8
26+
*/
27+
scrollThreshold?: number | string;
28+
/**
29+
* A scrollable parent element (or its DOM id string) to use as the
30+
* IntersectionObserver root. Defaults to the viewport when omitted.
31+
*/
32+
scrollableTarget?: HTMLElement | string | null;
33+
/**
34+
* Reverse scroll direction, sentinel is observed from the top. Use for
35+
* chat or messaging UIs with flex-direction: column-reverse.
36+
* @default false
37+
*/
38+
inverse?: boolean;
39+
}
40+
41+
export interface UseInfiniteScrollResult {
42+
/**
43+
* Attach this ref to a div at the bottom of your list (or top for inverse
44+
* mode). When it enters the viewport the hook calls next().
45+
*
46+
* @example
47+
* <ul>
48+
* {items.map(item => <li key={item.id}>{item.name}</li>)}
49+
* <li ref={sentinelRef} />
50+
* </ul>
51+
*/
52+
sentinelRef: RefObject<HTMLDivElement | null>;
53+
/**
54+
* True from when the sentinel fires until dataLength changes (i.e. new
55+
* data has arrived). Use this to show your own loading indicator.
56+
*/
57+
isLoading: boolean;
58+
}
59+
60+
/**
61+
* Low-level hook for building custom infinite scroll UIs.
62+
*
63+
* Manages an IntersectionObserver that watches a sentinel element you place
64+
* at the end of your list. When the sentinel enters the viewport, next() is
65+
* called. The hook handles deduplication and resets automatically when
66+
* dataLength changes.
67+
*
68+
* Use the InfiniteScroll component instead if you want a ready-made wrapper
69+
* with built-in loader, endMessage, pull-to-refresh, and inverse scroll UI.
70+
*
71+
* @example Basic usage
72+
* ```tsx
73+
* function Feed() {
74+
* const [items, setItems] = useState(initialItems);
75+
* const [hasMore, setHasMore] = useState(true);
76+
*
77+
* const { sentinelRef, isLoading } = useInfiniteScroll({
78+
* next: async () => {
79+
* const more = await fetchItems(items.length);
80+
* if (more.length === 0) { setHasMore(false); return; }
81+
* setItems(prev => [...prev, ...more]);
82+
* },
83+
* hasMore,
84+
* dataLength: items.length,
85+
* });
86+
*
87+
* return (
88+
* <ul>
89+
* {items.map(item => <li key={item.id}>{item.name}</li>)}
90+
* <li ref={sentinelRef} aria-hidden />
91+
* {isLoading && <li>Loading...</li>}
92+
* </ul>
93+
* );
94+
* }
95+
* ```
96+
*/
97+
export function useInfiniteScroll({
98+
next,
99+
hasMore,
100+
dataLength,
101+
scrollThreshold = 0.8,
102+
scrollableTarget,
103+
inverse = false,
104+
}: UseInfiniteScrollOptions): UseInfiniteScrollResult {
105+
const [isLoading, setIsLoading] = useState(false);
106+
const sentinelRef = useRef<HTMLDivElement>(null);
107+
const actionTriggeredRef = useRef(false);
108+
109+
// Stable ref so the observer callback always calls the latest next()
110+
// without triggering observer reconnection when an inline function is passed.
111+
const nextRef = useRef(next);
112+
nextRef.current = next;
113+
114+
const getScrollableNode = useCallback((): HTMLElement | null => {
115+
if (scrollableTarget instanceof HTMLElement) return scrollableTarget;
116+
if (typeof scrollableTarget === 'string') {
117+
return document.getElementById(scrollableTarget);
118+
}
119+
return null;
120+
}, [scrollableTarget]);
121+
122+
// Reset the load guard when new data arrives.
123+
useEffect(() => {
124+
actionTriggeredRef.current = false;
125+
setIsLoading(false);
126+
}, [dataLength]);
127+
128+
// IntersectionObserver lifecycle.
129+
useEffect(() => {
130+
if (!hasMore) return;
131+
if (typeof IntersectionObserver === 'undefined') return;
132+
133+
const sentinel = sentinelRef.current;
134+
if (!sentinel) return;
135+
136+
const root: Element | null = getScrollableNode();
137+
138+
const observer = new IntersectionObserver(
139+
([entry]) => {
140+
if (!entry.isIntersecting || actionTriggeredRef.current) return;
141+
actionTriggeredRef.current = true;
142+
setIsLoading(true);
143+
nextRef.current();
144+
},
145+
{
146+
root,
147+
rootMargin: buildRootMargin(scrollThreshold, inverse),
148+
threshold: 0,
149+
}
150+
);
151+
152+
observer.observe(sentinel);
153+
return () => observer.disconnect();
154+
}, [hasMore, scrollThreshold, inverse, getScrollableNode]);
155+
156+
return { sentinelRef, isLoading };
157+
}

0 commit comments

Comments
 (0)