Skip to content

Commit cfd26d5

Browse files
authored
feat: add useInfiniteScroll hook (#426)
## Summary - Adds `useInfiniteScroll` hook as a named export from the package - Uses the same IntersectionObserver sentinel pattern as the component, with stable callback refs to avoid observer churn - Adds JSDoc to the full `Props` interface in `src/index.tsx` for IDE autocomplete - Adds a `useInfiniteScroll (hook)` Storybook story demonstrating a fully custom list UI ## Usage ```tsx import { useInfiniteScroll } from 'react-infinite-scroll-component'; const { sentinelRef, isLoading } = useInfiniteScroll({ next: fetchMore, hasMore, dataLength: items.length, }); return ( <ul> {items.map(item => <li key={item.id}>{item.name}</li>)} <li ref={sentinelRef} aria-hidden="true" /> {isLoading && <li>Loading...</li>} </ul> ); ``` ## Test plan - [x] `yarn ts-check` passes - [x] `yarn test` — 54/54 tests pass - [x] `useInfiniteScroll (hook)` story renders and triggers load on scroll
1 parent d99aff0 commit cfd26d5

5 files changed

Lines changed: 500 additions & 2 deletions

File tree

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { RefObject } from 'react';
2+
import { render, cleanup, act } from '@testing-library/react';
3+
import { useInfiniteScroll, UseInfiniteScrollOptions } from '../index';
4+
import { MockIntersectionObserver } from './setup/intersectionObserverMock';
5+
6+
function HookWrapper(props: UseInfiniteScrollOptions) {
7+
const { sentinelRef, isLoading } = useInfiniteScroll(props);
8+
return (
9+
<div>
10+
<div
11+
ref={sentinelRef as RefObject<HTMLDivElement>}
12+
data-testid="sentinel"
13+
/>
14+
{isLoading && <div data-testid="loader" />}
15+
</div>
16+
);
17+
}
18+
19+
describe('useInfiniteScroll hook', () => {
20+
beforeEach(() => {
21+
MockIntersectionObserver.instances = [];
22+
});
23+
24+
afterEach(cleanup);
25+
26+
it('calls next when sentinel intersects', () => {
27+
const next = jest.fn();
28+
render(<HookWrapper next={next} hasMore={true} dataLength={10} />);
29+
30+
act(() => {
31+
MockIntersectionObserver.instances[0].triggerIntersect();
32+
});
33+
34+
expect(next).toHaveBeenCalledTimes(1);
35+
});
36+
37+
it('does not create an observer when hasMore is false', () => {
38+
render(<HookWrapper next={jest.fn()} hasMore={false} dataLength={10} />);
39+
expect(MockIntersectionObserver.instances).toHaveLength(0);
40+
});
41+
42+
it('does not call next twice before dataLength changes (load guard)', () => {
43+
const next = jest.fn();
44+
render(<HookWrapper next={next} hasMore={true} dataLength={10} />);
45+
46+
const observer = MockIntersectionObserver.instances[0];
47+
act(() => {
48+
observer.triggerIntersect();
49+
observer.triggerIntersect();
50+
});
51+
52+
expect(next).toHaveBeenCalledTimes(1);
53+
});
54+
55+
it('isLoading is true after sentinel fires and false after dataLength changes', () => {
56+
const next = jest.fn();
57+
const { getByTestId, queryByTestId, rerender } = render(
58+
<HookWrapper next={next} hasMore={true} dataLength={10} />
59+
);
60+
61+
expect(queryByTestId('loader')).toBeNull();
62+
63+
act(() => {
64+
MockIntersectionObserver.instances[0].triggerIntersect();
65+
});
66+
67+
expect(getByTestId('loader')).toBeTruthy();
68+
69+
rerender(<HookWrapper next={next} hasMore={true} dataLength={20} />);
70+
71+
expect(queryByTestId('loader')).toBeNull();
72+
});
73+
74+
it('resets load guard after dataLength changes so next can fire again', () => {
75+
const next = jest.fn();
76+
const { rerender } = render(
77+
<HookWrapper next={next} hasMore={true} dataLength={10} />
78+
);
79+
80+
act(() => {
81+
MockIntersectionObserver.instances[0].triggerIntersect();
82+
});
83+
expect(next).toHaveBeenCalledTimes(1);
84+
85+
rerender(<HookWrapper next={next} hasMore={true} dataLength={20} />);
86+
87+
act(() => {
88+
MockIntersectionObserver.instances[0].triggerIntersect();
89+
});
90+
expect(next).toHaveBeenCalledTimes(2);
91+
});
92+
93+
it('uses scrollableTarget string id as the IO root', () => {
94+
const target = document.createElement('div');
95+
target.id = 'hookScroll';
96+
document.body.appendChild(target);
97+
98+
render(
99+
<HookWrapper
100+
next={jest.fn()}
101+
hasMore={true}
102+
dataLength={5}
103+
scrollableTarget="hookScroll"
104+
/>
105+
);
106+
107+
expect(MockIntersectionObserver.instances[0].options.root).toBe(target);
108+
document.body.removeChild(target);
109+
});
110+
111+
it('accepts an HTMLElement directly as scrollableTarget', () => {
112+
const target = document.createElement('div');
113+
document.body.appendChild(target);
114+
115+
render(
116+
<HookWrapper
117+
next={jest.fn()}
118+
hasMore={true}
119+
dataLength={5}
120+
scrollableTarget={target}
121+
/>
122+
);
123+
124+
expect(MockIntersectionObserver.instances[0].options.root).toBe(target);
125+
document.body.removeChild(target);
126+
});
127+
128+
it('applies top rootMargin in inverse mode', () => {
129+
render(
130+
<HookWrapper
131+
next={jest.fn()}
132+
hasMore={true}
133+
dataLength={5}
134+
inverse={true}
135+
scrollThreshold={0.8}
136+
/>
137+
);
138+
139+
const { rootMargin } = MockIntersectionObserver.instances[0].options;
140+
expect(rootMargin).toBe('20% 0px 0px 0px');
141+
});
142+
143+
it('applies bottom rootMargin in normal (non-inverse) mode', () => {
144+
render(
145+
<HookWrapper
146+
next={jest.fn()}
147+
hasMore={true}
148+
dataLength={5}
149+
scrollThreshold={0.8}
150+
/>
151+
);
152+
153+
const { rootMargin } = MockIntersectionObserver.instances[0].options;
154+
expect(rootMargin).toBe('0px 0px 20% 0px');
155+
});
156+
157+
it('does not create an observer when sentinel ref is not attached', () => {
158+
function NoRefWrapper(props: UseInfiniteScrollOptions) {
159+
useInfiniteScroll(props);
160+
return <div />;
161+
}
162+
render(<NoRefWrapper next={jest.fn()} hasMore={true} dataLength={5} />);
163+
expect(MockIntersectionObserver.instances).toHaveLength(0);
164+
});
165+
166+
it('does not throw when IntersectionObserver is unavailable (SSR)', () => {
167+
const g = globalThis as Record<string, unknown>;
168+
const original = g['IntersectionObserver'];
169+
delete g['IntersectionObserver'];
170+
171+
expect(() => {
172+
render(<HookWrapper next={jest.fn()} hasMore={true} dataLength={5} />);
173+
}).not.toThrow();
174+
175+
g['IntersectionObserver'] = original;
176+
});
177+
});

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: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import WindowInf from './WindowInfiniteScrollComponent';
44
import PullDownToRefreshInfScroll from './PullDownToRefreshInfScroll';
55
import InfiniteScrollWithHeight from './InfiniteScrollWithHeight';
66
import ScrollableTargetInfiniteScroll from './ScrollableTargetInfScroll';
7-
import ScrollableTop from './ScrollableTop';
7+
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: () => <ScrollableTop />,
4041
};
42+
43+
export const UseInfiniteScrollHookStory: Story = {
44+
name: 'useInfiniteScroll (hook)',
45+
render: () => <UseInfiniteScrollHook />,
46+
};

0 commit comments

Comments
 (0)