Skip to content

Commit 31932b8

Browse files
committed
[IMPL] useInfiniteScroll hook
1 parent 7cd0b95 commit 31932b8

11 files changed

Lines changed: 334 additions & 10 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { useCallback, useMemo, useRef } from "react";
2+
import { useInfiniteScroll } from "../../../../../../packages/react-tools/src";
3+
4+
/**
5+
The component uses _useInfiniteScroll_ hook to render an items list that load all items while user scroll its container.
6+
*/
7+
export const UseInfiniteScroll = () => {
8+
const ref = useRef<HTMLDivElement>(null);
9+
const resultData = useMemo(() => Array(40).fill(undefined).map((_, index)=> index.toString()),[]);
10+
11+
const getLoadMoreList = useCallback((data?: string[]): Promise<string[]> => {
12+
let list;
13+
if (!data) {
14+
list = resultData.slice(0, 10);
15+
} else {
16+
const limit = 10;
17+
let start = 0;
18+
if (data!.length !== resultData.length) {
19+
start = data!.length;
20+
}
21+
const end = start + limit;
22+
list = [...data, ...resultData.slice(start, end)];
23+
}
24+
return new Promise((resolve) => {
25+
setTimeout(() => {
26+
resolve(list!);
27+
}, 1000);
28+
});
29+
}, [resultData]);
30+
31+
const { data, loading, loadData, fullData } = useInfiniteScroll<string[], HTMLDivElement>({
32+
request: getLoadMoreList,
33+
ref,
34+
hasMoreData: useCallback((d?: string[]) => (d || []).length !== resultData.length, [resultData]),
35+
threshold: 240
36+
});
37+
38+
return (<>
39+
<h2 style={{textAlign: "left"}}>Items List</h2>
40+
<div ref={ref} style={{ height: 250, overflow: 'auto', border: '1px solid', padding: 12 }}>
41+
<div>
42+
{data?.map((item) => (
43+
<div key={item} style={{ padding: 12, border: '1px solid #f5f5f5' }}>
44+
item-{Number(item) + 1}
45+
</div>
46+
))}
47+
</div>
48+
<div style={{ marginTop: 8 }}>
49+
{!fullData && (
50+
<button type="button" onClick={loadData} disabled={loading}>
51+
{loading ? 'Loading more...' : 'Click to load more'}
52+
</button>
53+
)}
54+
55+
{fullData && <span>No more data</span>}
56+
</div>
57+
</div>
58+
</>);
59+
}

apps/react-tools-demo/src/components/hooks/useLock/UseLock.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { useLock } from "../../../../../../packages/react-tools/src";
55
The component uses _useLock_ hook to simulate a buffer write by a producer and read from a consumer.
66
*/
77
export const UseLock = () => {
8-
const [buffer, setBuffer] = useState<number[]>([]);
8+
const [, setBuffer] = useState<number[]>([]);
99
const [lock, setLock] = useState<{ held: string[], pending: string[] }>({ held: [], pending: [] });
1010
const [messages, setMessages] = useState<{ consumer: string[], buffer: number[][], producer: string[] }>({
1111
consumer: [],

apps/react-tools-demo/src/constants/components.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,8 @@ export const COMPONENTS = [
7272
"usePinchZoom",
7373
"usePointerLock",
7474
"useContextMenu",
75-
"useSwipe"
75+
"useSwipe",
76+
"useInfiniteScroll"
7677
],
7778
//API DOM
7879
[
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# lazy
2+
Wrapper around _React.lazy_ with possibility to execute a function before and after component loading.
3+
4+
## API
5+
6+
```tsx
7+
lazy<T extends ComponentType<unknown>>(load: () => Promise<{ default: T }>, beforeLoad?: () => void, afterLoad?: () => void): LazyExoticComponent<T>
8+
```
9+
10+
> ### Params
11+
>
12+
> - __load__: _() => Promise<{ default: T }>_
13+
function that returns a Promise or another thenable.
14+
> - __beforeLoad__: _()=> void_
15+
function that will be executed before load component.
16+
> - __afterLoad__: _()=> void_
17+
function that will be executed after load component.
18+
>
19+
20+
> ### Returns
21+
>
22+
> __result__: a React component you can render in your tree.
23+
> - _LazyExoticComponent<T>_
24+
>
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# useInfiniteScroll
2+
Hook to deal with large sets of data. It allow users to scroll through content endlessly without explicit pagination or loading new pages.
3+
4+
## Usage
5+
6+
```tsx
7+
export const UseInfiniteScroll = () => {
8+
const ref = useRef<HTMLDivElement>(null);
9+
const resultData = useMemo(() => Array(40).fill(undefined).map((_, index)=> index.toString()),[]);
10+
11+
const getLoadMoreList = useCallback((data?: string[]): Promise<string[]> => {
12+
let list;
13+
if (!data) {
14+
list = resultData.slice(0, 10);
15+
} else {
16+
const limit = 10;
17+
let start = 0;
18+
if (data!.length !== resultData.length) {
19+
start = data!.length;
20+
}
21+
const end = start + limit;
22+
list = [...data, ...resultData.slice(start, end)];
23+
}
24+
return new Promise((resolve) => {
25+
setTimeout(() => {
26+
resolve(list!);
27+
}, 1000);
28+
});
29+
}, [resultData]);
30+
31+
const { data, loading, loadData, fullData } = useInfiniteScroll<string[], HTMLDivElement>({
32+
request: getLoadMoreList,
33+
ref,
34+
hasMoreData: useCallback((d?: string[]) => (d || []).length !== resultData.length, [resultData]),
35+
threshold: 240
36+
});
37+
38+
return (<>
39+
<h2 style={{textAlign: "left"}}>Items List</h2>
40+
<div ref={ref} style={{ height: 250, overflow: 'auto', border: '1px solid', padding: 12 }}>
41+
<div>
42+
{data?.map((item) => (
43+
<div key={item} style={{ padding: 12, border: '1px solid #f5f5f5' }}>
44+
item-{Number(item) + 1}
45+
</div>
46+
))}
47+
</div>
48+
<div style={{ marginTop: 8 }}>
49+
{!fullData && (
50+
<button type="button" onClick={loadData} disabled={loading}>
51+
{loading ? 'Loading more...' : 'Click to load more'}
52+
</button>
53+
)}
54+
55+
{fullData && <span>No more data</span>}
56+
</div>
57+
</div>
58+
</>);
59+
}
60+
```
61+
62+
> The component uses _useInfiniteScroll_ hook to render an items list that load all items while user scroll its container.
63+
64+
65+
## API
66+
67+
```tsx
68+
useInfiniteScroll<T, E extends Element>({ request, ref, hasMoreData, threshold, onBefore, onError, onSuccess }: { request: (data?: T) => Promise<T>, ref: RefObject<E>, hasMoreData: (data?: T) => boolean, threshold?: number, onBefore?: () => void, onSuccess?: () => void, onError?: (err: unknown) => void }): { data: T | undefined, loading: boolean, fullData: boolean, updateData: (data: T | ((currentState?: T) => T)) => void, loadData: () => Promise<void> }
69+
```
70+
71+
> ### Params
72+
>
73+
> - __param__: _Object_
74+
> - __param.request__: _(data?: T | undefined) => Promise<T>_
75+
request to obtain data.
76+
> - __param.ref__: _RefObject<E extends Element>_
77+
a reference to container element.
78+
> - __param.hasMoreData__: _(data?: T | undefined) => boolean_
79+
function that will be executed every time _data_ changes to detect if there will be new data values.
80+
> - __param.threshold=0?__: _number|undefined_
81+
a threshold value by which load next data during scroll.
82+
> - __param.onBefore?__: _()=>void_
83+
function that will be executed before to execute __request__.
84+
> - __param.onSuccess?__: _()=>void_
85+
function that will be executed if __request__ execution has success.
86+
> - __param.onError?__: _(err:unknown)=>void_
87+
function that will be executed if an error occurred calling __request__.
88+
>
89+
90+
> ### Returns
91+
>
92+
> __result__: __Object__:
93+
- __data__ : _T|undefined_
94+
- __loading__ : _boolean_
95+
- __fullData__ : _boolean_
96+
- __updateData__ : _(data:T|((currentState?:T)=>T))=>void_
97+
- __loadData__ : _()=>Promise<void>_
98+
> Object with these properties:
99+
> - __data__: data returned from _request_ execution.
100+
> - __loading__: boolean that will be true if a _request_ execution is in pending, otherwise it will be false.
101+
> - __fullData__: boolean that indicates if all data are returned or not.
102+
> - __updateData__: function to update data from outside.
103+
> - __loadData__: function to manual load next data.
104+
>

apps/react-tools-demo/src/markdown/useLock.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ Hook to use [Web Locks API](https://developer.mozilla.org/en-US/docs/Web/API/Web
55

66
```tsx
77
export const UseLock = () => {
8-
const [buffer, setBuffer] = useState<number[]>([]);
8+
const [, setBuffer] = useState<number[]>([]);
99
const [lock, setLock] = useState<{ held: string[], pending: string[] }>({ held: [], pending: [] });
1010
const [messages, setMessages] = useState<{ consumer: string[], buffer: number[][], producer: string[] }>({
1111
consumer: [],

packages/react-tools/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@
7373
- [x] usePointerLock
7474
- [x] useContextMenu
7575
- [x] useSwipe
76-
- [ ] useInfiniteScroll
76+
- [x] useInfiniteScroll
7777
- [ ] useDrag(TODO: need polyfill for mobile)
7878
- [ ] useDrop(TODO: need polyfill for mobile)
7979

packages/react-tools/src/hooks/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,4 +103,5 @@ export { usePromiseSuspensible } from './usePromiseSuspensible';
103103
export { useFetch } from './useFetch';
104104
export { useLock } from './useLock';
105105
export { useBroadcastChannel } from './useBroadcastChannel';
106-
export { useStateValidator } from './useStateValidator';
106+
export { useStateValidator } from './useStateValidator';
107+
export { useInfiniteScroll } from './useInfiniteScroll';

packages/react-tools/src/hooks/useEventListener.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@ import { useMemoizedFunction } from ".";
1111
* @param {"normal"|"layout"} [options.effectType="normal"] - option to set which hook is used to attach event listener.
1212
* @returns {()=>void} remove - used to manually remove the eventListener, otherwise is removed when component is unmounted.
1313
*/
14-
function useEventListener<T extends keyof DocumentEventMap>({ type, listener, element, listenerOpts, effectType }: { type: T | (T[]), listener: ((evt: DocumentEventMap[T]) => unknown | Promise<unknown>), element?: RefObject<Element> | Element | Window, listenerOpts?: boolean | AddEventListenerOptions, effectType?: "normal" | "layout" }): (() => void);
15-
function useEventListener<T extends keyof HTMLElementEventMap>({ type, listener, element, listenerOpts, effectType }: { type: T | (T[]), listener: ((evt: HTMLElementEventMap[T]) => unknown | Promise<unknown>), element?: RefObject<Element> | Element | Window, listenerOpts?: boolean | AddEventListenerOptions, effectType?: "normal" | "layout" }): (() => void);
16-
function useEventListener<T extends string, E extends Event|CustomEvent>({ type, listener, element, listenerOpts, effectType }: { type: T | (T[]), listener: ((evt: E) => unknown | Promise<unknown>), element?: RefObject<Element> | Element | Window, listenerOpts?: boolean | AddEventListenerOptions, effectType?: "normal" | "layout" }): (() => void);
17-
function useEventListener<T extends keyof WindowEventMap>({ type, listener, element = window, listenerOpts, effectType = "normal" }: { type: T|(T[]), listener: ((evt: WindowEventMap[T]) => unknown | Promise<unknown>), element?: RefObject<Element> | Element | Window, listenerOpts?: boolean | AddEventListenerOptions, effectType?: "normal" | "layout" }): (() => void) {
14+
function useEventListener<T extends keyof DocumentEventMap, E extends Element>({ type, listener, element, listenerOpts, effectType }: { type: T | (T[]), listener: ((evt: DocumentEventMap[T]) => unknown | Promise<unknown>), element?: RefObject<E> | E | Window, listenerOpts?: boolean | AddEventListenerOptions, effectType?: "normal" | "layout" }): (() => void);
15+
function useEventListener<T extends keyof HTMLElementEventMap, E extends Element>({ type, listener, element, listenerOpts, effectType }: { type: T | (T[]), listener: ((evt: HTMLElementEventMap[T]) => unknown | Promise<unknown>), element?: RefObject<E> | E | Window, listenerOpts?: boolean | AddEventListenerOptions, effectType?: "normal" | "layout" }): (() => void);
16+
function useEventListener<T extends string, E extends Event|CustomEvent, S extends Element>({ type, listener, element, listenerOpts, effectType }: { type: T | (T[]), listener: ((evt: E) => unknown | Promise<unknown>), element?: RefObject<S> | S | Window, listenerOpts?: boolean | AddEventListenerOptions, effectType?: "normal" | "layout" }): (() => void);
17+
function useEventListener<T extends keyof WindowEventMap, E extends Element>({ type, listener, element = window, listenerOpts, effectType = "normal" }: { type: T|(T[]), listener: ((evt: WindowEventMap[T]) => unknown | Promise<unknown>), element?: RefObject<E> | E | Window, listenerOpts?: boolean | AddEventListenerOptions, effectType?: "normal" | "layout" }): (() => void) {
1818
const optsMemoized = useRef<typeof listenerOpts>(listenerOpts);
1919
const elementReference = useRef<Element | Window | null>();
2020
const effect = effectType === "layout" ? useLayoutEffect : useEffect;
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { RefObject, useCallback, useEffect, useMemo, useRef } from "react";
2+
import { useSyncExternalStore } from ".";
3+
4+
/**
5+
* **`useInfiniteScroll`**: Hook to deal with large sets of data. It allow users to scroll through content endlessly without explicit pagination or loading new pages.
6+
* @param {Object} param
7+
* @param {(data?: T | undefined) => Promise<T>} param.request - request to obtain data.
8+
* @param {RefObject<E extends Element>} param.ref - a reference to container element.
9+
* @param {(data?: T | undefined) => boolean} param.hasMoreData - function that will be executed every time _data_ changes to detect if there will be new data values.
10+
* @param {number|undefined} [param.threshold=0] - a threshold value by which load next data during scroll.
11+
* @param {()=>void} [param.onBefore] - function that will be executed before to execute __request__.
12+
* @param {()=>void} [param.onSuccess] - function that will be executed if __request__ execution has success.
13+
* @param {(err:unknown)=>void} [param.onError] - function that will be executed if an error occurred calling __request__.
14+
* @returns {{data: T|undefined, loading: boolean, fullData: boolean, updateData: (data:T|((currentState?:T)=>T))=>void, loadData: ()=>Promise<void>}} result
15+
* Object with these properties:
16+
* - __data__: data returned from _request_ execution.
17+
* - __loading__: boolean that will be true if a _request_ execution is in pending, otherwise it will be false.
18+
* - __fullData__: boolean that indicates if all data are returned or not.
19+
* - __updateData__: function to update data from outside.
20+
* - __loadData__: function to manual load next data.
21+
*/
22+
export const useInfiniteScroll = <T, E extends Element>({ request, ref, hasMoreData, threshold, onBefore, onError, onSuccess }: { request: (data?: T) => Promise<T>, ref: RefObject<E>, hasMoreData: (data?: T) => boolean, threshold?: number, onBefore?: () => void, onSuccess?: () => void, onError?: (err: unknown) => void }): { data: T | undefined, loading: boolean, fullData: boolean, updateData: (data: T | ((currentState?: T) => T)) => void, loadData: () => Promise<void> } => {
23+
const notifyRef = useRef<() => void>();
24+
const cachedState = useRef<{ data?: T, loading: boolean, fullData: boolean }>({
25+
data: undefined,
26+
loading: true,
27+
fullData: !hasMoreData(),
28+
});
29+
30+
const loadData = useCallback(() => {
31+
if (cachedState.current.loading) {
32+
return Promise.resolve();
33+
}
34+
cachedState.current.loading = true;
35+
!!notifyRef.current && notifyRef.current();
36+
!!onBefore && onBefore();
37+
return request(cachedState.current.data)
38+
.then(data => {
39+
cachedState.current.data = data;
40+
cachedState.current.loading = false;
41+
cachedState.current.fullData = !hasMoreData(data);
42+
!!notifyRef.current && notifyRef.current();
43+
!!onSuccess && onSuccess();
44+
})
45+
.catch(err => {
46+
cachedState.current.loading = false;
47+
!!notifyRef.current && notifyRef.current();
48+
!!onError && onError(err);
49+
});
50+
}, [onBefore, onSuccess, onError, request, hasMoreData]);
51+
52+
const loadOnScroll = useCallback((el: Element) => {
53+
if (cachedState.current.loading || cachedState.current.fullData) {
54+
return
55+
}
56+
const scrollTop = el === (document.documentElement as Element)
57+
? Math.max(
58+
window.scrollY,
59+
(document.documentElement as Element).scrollTop,
60+
document.body.scrollTop,
61+
)
62+
: el.scrollTop;
63+
const scrollHeight = el.scrollHeight || Math.max(document.documentElement.scrollHeight, document.body.scrollHeight);
64+
const clientHeight = el.clientHeight || Math.max(document.documentElement.clientHeight, document.body.clientHeight);
65+
66+
(scrollHeight - scrollTop <= clientHeight + (threshold ?? 0)) && loadData();
67+
}, [loadData, threshold]);
68+
69+
const updateData = useCallback((data: T|((currentState?:T)=>T)) => {
70+
cachedState.current.data = data instanceof Function ? data(cachedState.current.data) : data;
71+
!!notifyRef.current && notifyRef.current();
72+
}, []);
73+
74+
const state = useSyncExternalStore(
75+
useCallback(notif => {
76+
notifyRef.current = notif;
77+
const el = ref.current as Element;
78+
const onScroll = () => loadOnScroll(el);
79+
el && el.addEventListener("scroll", onScroll, { passive: true });
80+
return () => {
81+
notifyRef.current = undefined;
82+
el && el.removeEventListener("scroll", onScroll);
83+
}
84+
}, [loadOnScroll, ref]),
85+
useMemo(() => {
86+
let state: typeof cachedState.current = {
87+
loading: true,
88+
fullData: cachedState.current.fullData
89+
};
90+
return () => {
91+
if (state.data !== cachedState.current.data) {
92+
state.data = (cachedState.current.data === undefined
93+
? undefined
94+
: Array.isArray(cachedState.current.data)
95+
? [...cachedState.current.data]
96+
: typeof cachedState.current.data === "object"
97+
? { ...cachedState.current.data }
98+
: cachedState.current.data) as T|undefined;
99+
state = { ...cachedState.current };
100+
}
101+
if (state.loading !== cachedState.current.loading || state.fullData !== cachedState.current.fullData) {
102+
state = { ...cachedState.current };
103+
}
104+
return state;
105+
}
106+
}, [])
107+
);
108+
109+
useEffect(() => {
110+
!!onBefore && onBefore();
111+
cachedState.current.loading = true;
112+
request()
113+
.then(data => {
114+
cachedState.current.data = data;
115+
cachedState.current.loading = false;
116+
cachedState.current.fullData = !hasMoreData(data);
117+
!!notifyRef.current && notifyRef.current();
118+
!!onSuccess && onSuccess();
119+
})
120+
.catch(err => {
121+
cachedState.current.loading = false;
122+
!!notifyRef.current && notifyRef.current();
123+
!!onError && onError(err);
124+
})
125+
}, [request, onError, onBefore, onSuccess, hasMoreData]);
126+
127+
return {
128+
data: state.data,
129+
loading: state.loading,
130+
fullData: state.fullData,
131+
updateData,
132+
loadData
133+
}
134+
}

0 commit comments

Comments
 (0)