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