typescriptadvanced

useInfiniteQuery Hook

Implement infinite scrolling with cursor-based pagination, loading states, and intersection observer.

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

interface Page<T> {
  data: T[];
  nextCursor: string | null;
}

interface InfiniteQueryResult<T> {
  data: T[];
  loading: boolean;
  loadingMore: boolean;
  error: Error | null;
  hasMore: boolean;
  loadMore: () => void;
  sentinelRef: (node: HTMLElement | null) => void;
}

function useInfiniteQuery<T>(
  fetcher: (cursor?: string) => Promise<Page<T>>,
  deps: unknown[] = [],
): InfiniteQueryResult<T> {
  const [pages, setPages] = useState<Page<T>[]>([]);
  const [loading, setLoading] = useState(true);
  const [loadingMore, setLoadingMore] = useState(false);
  const [error, setError] = useState<Error | null>(null);
  const observerRef = useRef<IntersectionObserver | null>(null);
  const cursorRef = useRef<string | null>(null);
  const isFetchingRef = useRef(false);

  // Initial fetch
  useEffect(() => {
    setLoading(true);
    setPages([]);
    cursorRef.current = null;

    fetcher()
      .then((page) => {
        setPages([page]);
        cursorRef.current = page.nextCursor;
        setLoading(false);
      })
      .catch((err) => { setError(err); setLoading(false); });
  }, deps); // eslint-disable-line react-hooks/exhaustive-deps

  const loadMore = useCallback(async () => {
    if (isFetchingRef.current || !cursorRef.current) return;
    isFetchingRef.current = true;
    setLoadingMore(true);

    try {
      const page = await fetcher(cursorRef.current);
      setPages((prev) => [...prev, page]);
      cursorRef.current = page.nextCursor;
    } catch (err) {
      setError(err as Error);
    } finally {
      setLoadingMore(false);
      isFetchingRef.current = false;
    }
  }, [fetcher]);

  // IntersectionObserver sentinel ref callback
  const sentinelRef = useCallback(
    (node: HTMLElement | null) => {
      if (observerRef.current) observerRef.current.disconnect();
      if (!node) return;

      observerRef.current = new IntersectionObserver(
        (entries) => {
          if (entries[0]?.isIntersecting && cursorRef.current) loadMore();
        },
        { rootMargin: '200px' },
      );
      observerRef.current.observe(node);
    },
    [loadMore],
  );

  const data = pages.flatMap((p) => p.data);
  const hasMore = cursorRef.current !== null;

  return { data, loading, loadingMore, error, hasMore, loadMore, sentinelRef };
}

// Usage
// function Feed() {
//   const { data, loading, loadingMore, hasMore, sentinelRef } = useInfiniteQuery(
//     (cursor) => fetchPosts(cursor),
//   );
//   return (
//     <div>
//       {data.map(post => <PostCard key={post.id} {...post} />)}
//       {hasMore && <div ref={sentinelRef} className="h-4" />}
//       {loadingMore && <Spinner />}
//     </div>
//   );
// }

export { useInfiniteQuery };

Use Cases

  • Infinite scroll feeds
  • Paginated API lists
  • Social media timelines

Tags

Related Snippets

Similar patterns you can reuse in the same workflow.