Documentation
Templates
Components
Data Display
Infinite List

Infinite List

A performant infinite scroll implementation with TanStack Query and intersection observer.

Overview

This template provides:

  • Infinite scroll with TanStack Query
  • Intersection observer for scroll detection
  • Loading states and error handling
  • Pull-to-refresh support
  • Optional virtualization for large lists
  • Skeleton loading placeholders

Dependencies

npm install @tanstack/react-query

For virtualization (optional):

npm install @tanstack/react-virtual

Code

import { useRef, useCallback, useEffect } from 'react';
import { Loader2 } from 'lucide-react';
 
interface InfiniteListProps<T> {
  items: T[];
  renderItem: (item: T, index: number) => React.ReactNode;
  keyExtractor: (item: T) => string;
  onLoadMore: () => void;
  hasNextPage: boolean;
  isFetchingNextPage: boolean;
  isLoading: boolean;
  isError: boolean;
  error?: Error | null;
  emptyMessage?: string;
  loadingComponent?: React.ReactNode;
  errorComponent?: React.ReactNode;
  className?: string;
}
 
export function InfiniteList<T>({
  items,
  renderItem,
  keyExtractor,
  onLoadMore,
  hasNextPage,
  isFetchingNextPage,
  isLoading,
  isError,
  error,
  emptyMessage = 'No items found',
  loadingComponent,
  errorComponent,
  className,
}: InfiniteListProps<T>) {
  const observerRef = useRef<IntersectionObserver | null>(null);
 
  const lastItemRef = useCallback(
    (node: HTMLDivElement | null) => {
      if (isFetchingNextPage) return;
 
      if (observerRef.current) {
        observerRef.current.disconnect();
      }
 
      observerRef.current = new IntersectionObserver(
        (entries) => {
          if (entries[0].isIntersecting && hasNextPage) {
            onLoadMore();
          }
        },
        { threshold: 0.1, rootMargin: '100px' }
      );
 
      if (node) {
        observerRef.current.observe(node);
      }
    },
    [isFetchingNextPage, hasNextPage, onLoadMore]
  );
 
  // Cleanup observer on unmount
  useEffect(() => {
    return () => {
      if (observerRef.current) {
        observerRef.current.disconnect();
      }
    };
  }, []);
 
  // Initial loading state
  if (isLoading) {
    return loadingComponent ?? <LoadingSkeletons count={5} />;
  }
 
  // Error state
  if (isError) {
    return (
      errorComponent ?? (
        <div className="flex flex-col items-center justify-center py-12 text-center">
          <p className="text-red-500">Failed to load items</p>
          <p className="text-sm text-muted-foreground">{error?.message}</p>
        </div>
      )
    );
  }
 
  // Empty state
  if (items.length === 0) {
    return (
      <div className="flex flex-col items-center justify-center py-12 text-center">
        <p className="text-muted-foreground">{emptyMessage}</p>
      </div>
    );
  }
 
  return (
    <div className={className}>
      {items.map((item, index) => (
        <div
          key={keyExtractor(item)}
          ref={index === items.length - 1 ? lastItemRef : undefined}
        >
          {renderItem(item, index)}
        </div>
      ))}
 
      {/* Loading indicator for next page */}
      {isFetchingNextPage && (
        <div className="flex justify-center py-4">
          <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
        </div>
      )}
 
      {/* End of list indicator */}
      {!hasNextPage && items.length > 0 && (
        <p className="py-4 text-center text-sm text-muted-foreground">
          No more items to load
        </p>
      )}
    </div>
  );
}
 
function LoadingSkeletons({ count }: { count: number }) {
  return (
    <div className="space-y-4">
      {Array.from({ length: count }).map((_, i) => (
        <div
          key={i}
          className="h-24 animate-pulse rounded-lg bg-muted"
        />
      ))}
    </div>
  );
}

Virtualization

For very large lists (1000+ items), add virtualization to render only visible items:

import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
 
function VirtualizedList<T>({
  items,
  renderItem,
  estimateSize = 100,
}: {
  items: T[];
  renderItem: (item: T, index: number) => React.ReactNode;
  estimateSize?: number;
}) {
  const parentRef = useRef<HTMLDivElement>(null);
 
  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => estimateSize,
    overscan: 5,
  });
 
  return (
    <div ref={parentRef} className="h-[600px] overflow-auto">
      <div
        style={{
          height: `${virtualizer.getTotalSize()}px`,
          position: 'relative',
        }}
      >
        {virtualizer.getVirtualItems().map((virtualItem) => (
          <div
            key={virtualItem.key}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              transform: `translateY(${virtualItem.start}px)`,
            }}
          >
            {renderItem(items[virtualItem.index], virtualItem.index)}
          </div>
        ))}
      </div>
    </div>
  );
}

Pull to Refresh

Add pull-to-refresh functionality:

import { useCallback, useState } from 'react';
 
function usePullToRefresh(onRefresh: () => Promise<void>) {
  const [isRefreshing, setIsRefreshing] = useState(false);
 
  const handleRefresh = useCallback(async () => {
    setIsRefreshing(true);
    try {
      await onRefresh();
    } finally {
      setIsRefreshing(false);
    }
  }, [onRefresh]);
 
  return { isRefreshing, handleRefresh };
}
 
// Usage with react-pull-to-refresh or similar library

API Response Format

The infinite query expects this response format:

interface PaginatedResponse<T> {
  items: T[];          // Array of items for current page
  nextCursor?: string; // Cursor for next page (undefined if no more)
  hasMore: boolean;    // Whether more items exist
}

Cursor vs Offset Pagination

ApproachProsCons
CursorConsistent with real-time data, performantComplex to implement
OffsetSimple, jump to any pageInconsistent with changing data
Use cursor-based pagination for infinite scroll. Offset pagination can cause duplicate or missing items when data changes.

Related