From 9507b83467eafededbcae891d3c9c1dfb1fdc8b9 Mon Sep 17 00:00:00 2001 From: CIFERBANS123 Date: Wed, 26 Aug 2026 10:25:10 +0000 Subject: [PATCH] fix(hooks): stop IntersectionObserver recreating on every loading toggle (#898) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add loadingRef (synced to loading state via useEffect) so the observer callback can read the latest loading value without a dep on the state var. - Add runLoadMoreRef (updated on every render, no deps) so the observer callback always invokes the latest runLoadMore without listing it as a dep. - Remove loading and runLoadMore from the observer effect dep array; deps are now only [hasNextPage, rootMargin, threshold]. - Remove loading from runLoadMore's useCallback dep array (read via ref). Result: the IntersectionObserver is only torn down and recreated when hasNextPage, rootMargin, or threshold actually change — not on every loading state flip, which was causing missed/duplicate callbacks during rapid scrolling. --- src/hooks/useInfiniteScroll.ts | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/src/hooks/useInfiniteScroll.ts b/src/hooks/useInfiniteScroll.ts index fc7a9ff4..a19bfc8b 100644 --- a/src/hooks/useInfiniteScroll.ts +++ b/src/hooks/useInfiniteScroll.ts @@ -38,8 +38,17 @@ export function useInfiniteScroll({ const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + // Keep a ref in sync with the loading state so the observer callback can + // read the latest value without being listed as an effect dependency. + // This prevents the IntersectionObserver from being torn down and recreated + // on every loading transition. + const loadingRef = useRef(loading); + useEffect(() => { + loadingRef.current = loading; + }, [loading]); + const runLoadMore = useCallback(async () => { - if (loading || !hasNextPage) return; + if (loadingRef.current || !hasNextPage) return; setLoading(true); setError(null); @@ -51,7 +60,15 @@ export function useInfiniteScroll({ } finally { setLoading(false); } - }, [loading, hasNextPage, onLoadMore]); + }, [hasNextPage, onLoadMore]); + + // Keep a stable ref to runLoadMore so the observer effect does not need to + // list it as a dependency. The ref is updated on every render, meaning the + // callback inside the observer always calls the latest version. + const runLoadMoreRef = useRef(runLoadMore); + useEffect(() => { + runLoadMoreRef.current = runLoadMore; + }); const loadMore = useCallback(() => { void runLoadMore(); @@ -64,8 +81,10 @@ export function useInfiniteScroll({ const observer = new IntersectionObserver( (entries) => { const first = entries[0]; - if (first?.isIntersecting && !loading) { - void runLoadMore(); + // Read loading from the ref — no need to list it as a dep, so the + // observer is never recreated just because loading flipped. + if (first?.isIntersecting && !loadingRef.current) { + void runLoadMoreRef.current(); } }, { threshold, rootMargin }, @@ -74,7 +93,8 @@ export function useInfiniteScroll({ observer.observe(sentinel); return () => observer.disconnect(); - }, [hasNextPage, loading, rootMargin, runLoadMore, threshold]); + // loading and runLoadMore intentionally omitted — accessed via refs above. + }, [hasNextPage, rootMargin, threshold]); return { sentinelRef, loading, error, loadMore }; }