/** * useInfiniteScroll Hook * * Provides infinite scrolling functionality for any scrollable container. * Automatically loads more results when user scrolls near the bottom. * * @example * ```tsx * const { scrollContainerRef, isNearBottom } = useInfiniteScroll({ * onLoadMore: handleLoadMoreResults, * hasMore: paginationState?.hasMore ?? false, * isLoading: isLoadingMore, * threshold: 200, // pixels from bottom * }); * * return
...
* ``` */ import { useCallback, useEffect, useRef, useState } from 'react'; export interface UseInfiniteScrollOptions { /** Callback to load more results */ onLoadMore: () => Promise | void; /** Whether there are more results to load */ hasMore: boolean; /** Whether currently loading */ isLoading: boolean; /** Distance from bottom (in pixels) to trigger load */ threshold?: number; /** Whether infinite scroll is enabled (allows toggle between button and scroll) */ enabled?: boolean; /** Debounce delay in milliseconds */ debounceMs?: number; } export interface UseInfiniteScrollReturn { /** Ref to attach to the scrollable container */ scrollContainerRef: React.RefObject; /** Whether user is near the bottom of the container */ isNearBottom: boolean; /** Manually trigger scroll position check */ checkScrollPosition: () => void; } export function useInfiniteScroll({ onLoadMore, hasMore, isLoading, threshold = 200, enabled = true, debounceMs = 150, }: UseInfiniteScrollOptions): UseInfiniteScrollReturn { const scrollContainerRef = useRef(null); const [isNearBottom, setIsNearBottom] = useState(false); const loadingRef = useRef(false); const debounceTimeoutRef = useRef | null>(null); // Check if user has scrolled near the bottom const checkScrollPosition = useCallback(() => { const container = scrollContainerRef.current; if (!container) return; const { scrollTop, scrollHeight, clientHeight } = container; const distanceFromBottom = scrollHeight - scrollTop - clientHeight; const nearBottom = distanceFromBottom < threshold; setIsNearBottom(nearBottom); // Trigger load if conditions are met if (enabled && nearBottom && hasMore && !isLoading && !loadingRef.current) { loadingRef.current = true; // Call onLoadMore and reset loading ref when done const result = onLoadMore(); if (result instanceof Promise) { result.finally(() => { loadingRef.current = false; }); } else { // For synchronous handlers, reset after a short delay setTimeout(() => { loadingRef.current = false; }, 100); } } }, [onLoadMore, hasMore, isLoading, threshold, enabled]); // Debounced scroll handler const handleScroll = useCallback(() => { if (debounceTimeoutRef.current) { clearTimeout(debounceTimeoutRef.current); } debounceTimeoutRef.current = setTimeout(() => { checkScrollPosition(); }, debounceMs); }, [checkScrollPosition, debounceMs]); // Attach scroll listener useEffect(() => { const container = scrollContainerRef.current; if (!container || !enabled) return; container.addEventListener('scroll', handleScroll, { passive: true }); // Initial check in case content is already scrolled or container is small checkScrollPosition(); return () => { container.removeEventListener('scroll', handleScroll); if (debounceTimeoutRef.current) { clearTimeout(debounceTimeoutRef.current); } }; }, [handleScroll, checkScrollPosition, enabled]); // Re-check when hasMore or isLoading changes useEffect(() => { if (enabled && hasMore && !isLoading) { checkScrollPosition(); } }, [hasMore, isLoading, enabled, checkScrollPosition]); return { scrollContainerRef, isNearBottom, checkScrollPosition, }; } export default useInfiniteScroll;