useInfiniteScroll.tsx raw
1 import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
3 export interface UseInfiniteScrollOptions<T> {
4 /**
5 * The initial data items
6 */
7 items: T[]
8 /**
9 * Whether to initially show all items or use pagination
10 * @default false
11 */
12 showAllInitially?: boolean
13 /**
14 * Number of items to show initially and load per batch
15 * @default 10
16 */
17 showCount?: number
18 /**
19 * Initial loading state, which can be used to prevent loading more data until initial load is complete
20 */
21 initialLoading?: boolean
22 /**
23 * The function to load more data
24 * Returns true if there are more items to load, false otherwise
25 */
26 onLoadMore: () => Promise<boolean>
27 /**
28 * IntersectionObserver options
29 */
30 observerOptions?: IntersectionObserverInit
31 }
32
33 const DEFAULT_OBSERVER_OPTIONS: IntersectionObserverInit = {
34 root: null,
35 rootMargin: '100px',
36 threshold: 0
37 }
38
39 export function useInfiniteScroll<T>({
40 items,
41 showAllInitially = false,
42 showCount: initialShowCount = 10,
43 onLoadMore,
44 initialLoading = false,
45 observerOptions = DEFAULT_OBSERVER_OPTIONS
46 }: UseInfiniteScrollOptions<T>) {
47 const [hasMore, setHasMore] = useState(true)
48 const [showCount, setShowCount] = useState(showAllInitially ? Infinity : initialShowCount)
49 const [loading, setLoading] = useState(false)
50 const bottomRef = useRef<HTMLDivElement | null>(null)
51
52 // Store all mutable state in a ref so the loadMore callback never goes stale
53 const stateRef = useRef({
54 loading,
55 hasMore,
56 showCount,
57 itemsLength: items.length,
58 initialLoading,
59 onLoadMore
60 })
61
62 stateRef.current = {
63 loading,
64 hasMore,
65 showCount,
66 itemsLength: items.length,
67 initialLoading,
68 onLoadMore
69 }
70
71 // Stable callback — never changes identity, reads everything from stateRef
72 const loadMore = useCallback(async () => {
73 const { loading, hasMore, showCount, itemsLength, initialLoading, onLoadMore } =
74 stateRef.current
75
76 if (initialLoading || loading) return
77
78 // If there are more items to show, increase showCount first
79 if (showCount < itemsLength) {
80 setShowCount((prev) => prev + initialShowCount)
81 // Only fetch more data when remaining items are running low
82 if (itemsLength - showCount > initialShowCount * 2) {
83 return
84 }
85 }
86
87 if (!hasMore) return
88 setLoading(true)
89 const newHasMore = await onLoadMore()
90 setHasMore(newHasMore)
91 setLoading(false)
92 }, [initialShowCount])
93
94 const isIntersectingRef = useRef(false)
95
96 // IntersectionObserver — created once, never torn down/rebuilt
97 useEffect(() => {
98 const currentBottomRef = bottomRef.current
99 if (!currentBottomRef) return
100
101 const observer = new IntersectionObserver((entries) => {
102 isIntersectingRef.current = entries[0].isIntersecting
103 if (entries[0].isIntersecting) {
104 loadMore()
105 }
106 }, observerOptions)
107
108 observer.observe(currentBottomRef)
109
110 return () => {
111 observer.disconnect()
112 }
113 }, []) // eslint-disable-line react-hooks/exhaustive-deps
114
115 // Re-trigger loadMore when items change while bottom ref is visible.
116 // Also reset hasMore so a stale false doesn't block future loads.
117 useEffect(() => {
118 if (items.length > 0) {
119 setHasMore(true)
120 }
121 if (isIntersectingRef.current) {
122 loadMore()
123 }
124 }, [items.length]) // eslint-disable-line react-hooks/exhaustive-deps
125
126 const visibleItems = useMemo(() => {
127 return showAllInitially ? items : items.slice(0, showCount)
128 }, [items, showAllInitially, showCount])
129
130 const shouldShowLoadingIndicator = hasMore || showCount < items.length || loading
131
132 return {
133 visibleItems,
134 loading,
135 hasMore,
136 shouldShowLoadingIndicator,
137 bottomRef,
138 setHasMore,
139 setLoading
140 }
141 }
142