typescriptintermediate

Infinite Scroll with Intersection Observer

Load more items as the user scrolls using IntersectionObserver. No external libraries required.

typescript
import { useEffect, useRef, useState, useCallback } from 'react';

interface UseInfiniteScrollOpts<T> {
  fetchPage: (page: number) => Promise<T[]>;
  initialPage?: number;
}

export function useInfiniteScroll<T>({ fetchPage, initialPage = 1 }: UseInfiniteScrollOpts<T>) {
  const [items, setItems] = useState<T[]>([]);
  const [page, setPage] = useState(initialPage);
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);
  const sentinelRef = useRef<HTMLDivElement | null>(null);

  const loadMore = useCallback(async () => {
    if (loading || !hasMore) return;
    setLoading(true);
    const newItems = await fetchPage(page);
    if (newItems.length === 0) {
      setHasMore(false);
    } else {
      setItems((prev) => [...prev, ...newItems]);
      setPage((p) => p + 1);
    }
    setLoading(false);
  }, [fetchPage, page, loading, hasMore]);

  useEffect(() => {
    const sentinel = sentinelRef.current;
    if (!sentinel) return;

    const observer = new IntersectionObserver(
      ([entry]) => { if (entry.isIntersecting) loadMore(); },
      { rootMargin: '200px' }
    );
    observer.observe(sentinel);
    return () => observer.disconnect();
  }, [loadMore]);

  return { items, loading, hasMore, sentinelRef };
}

// Usage:
// const { items, loading, sentinelRef } = useInfiniteScroll({
//   fetchPage: (page) => fetch(`/api/posts?page=${page}`).then(r => r.json()),
// });
// return <>{items.map(renderItem)}<div ref={sentinelRef} /></>;

Use Cases

  • Feed pagination
  • Product listing pages
  • Comment threads

Tags

Related Snippets

Similar patterns you can reuse in the same workflow.