Skip to content

Add Infinite Query Support for Pagination #39

@AliceR

Description

@AliceR

Problem

Current pagination in useStacSearch uses next/previous page functions that replace the current results. This doesn't support:

  • Infinite scroll UI patterns
  • "Load more" buttons
  • Accumulating results across pages
  • Automatic pagination as user scrolls

Modern STAC viewers need infinite scroll to display large result sets without traditional pagination controls.

Current Behavior

const { results, nextPage, previousPage } = useStacSearch();

// results contains only the current page
// nextPage() replaces results with next page
// Can't accumulate results for infinite scroll

Desired Behavior

function InfiniteResults() {
  const {
    data, // All pages combined
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useStacSearchInfinite({
    params: { collections: ['landsat-8'] },
  });
  
  // Flatten all pages
  const allItems = data?.pages.flatMap(page => page.features) ?? [];
  
  return (
    <div>
      <ItemsGrid items={allItems} />
      {hasNextPage && (
        <button onClick={fetchNextPage} disabled={isFetchingNextPage}>
          {isFetchingNextPage ? 'Loading...' : 'Load More'}
        </button>
      )}
    </div>
  );
}

Use Cases from stac-map

stac-map uses useInfiniteQuery for both collections and search:

// Collections with infinite loading
const collectionsQuery = useInfiniteQuery({
  queryKey: ['stac-collections', href],
  queryFn: async ({ pageParam }) => fetchCollections(pageParam),
  initialPageParam: href,
  getNextPageParam: (lastPage) => 
    lastPage?.links?.find(link => link.rel === 'next')?.href,
});

// Search with infinite loading  
const searchQuery = useInfiniteQuery({
  queryKey: ['search', search, link],
  initialPageParam: updateLink(link, search),
  getNextPageParam: (lastPage) => 
    lastPage.links?.find(link => link.rel === 'next'),
  queryFn: fetchSearch,
});

Proposed Solution

Hook Signature

type UseStacSearchInfiniteOptions = {
  /** Search parameters */
  params: StacSearchParams;
  
  /** Optional: specific search link to use */
  searchLink?: Link;
  
  /** Enable/disable search */
  enabled?: boolean;
  
  /** Custom headers */
  headers?: Record<string, string>;
};

type UseStacSearchInfiniteResult = {
  /** All pages of results */
  data?: {
    pages: SearchResponse[];
    pageParams: (Link | undefined)[];
  };
  
  /** All items flattened */
  items?: Item[];
  
  /** Fetch next page */
  fetchNextPage: () => Promise<void>;
  
  /** Fetch previous page (if supported) */
  fetchPreviousPage?: () => Promise<void>;
  
  /** Whether there are more pages */
  hasNextPage: boolean;
  hasPreviousPage?: boolean;
  
  /** Loading states */
  isLoading: boolean;
  isFetchingNextPage: boolean;
  isFetchingPreviousPage?: boolean;
  
  /** Error state */
  error?: ApiErrorType;
  
  /** Refetch all pages */
  refetch: () => Promise<void>;
};

function useStacSearchInfinite(
  options: UseStacSearchInfiniteOptions
): UseStacSearchInfiniteResult;

Implementation

import { useInfiniteQuery } from '@tanstack/react-query';
import { useMemo } from 'react';

function useStacSearchInfinite({
  params,
  searchLink,
  enabled = true,
  headers = {},
}: UseStacSearchInfiniteOptions): UseStacSearchInfiniteResult {
  const { stacApi } = useStacApiContext();
  
  const query = useInfiniteQuery({
    queryKey: ['stac-search-infinite', params, searchLink?.href],
    
    queryFn: async ({ pageParam }) => {
      if (pageParam) {
        // Use pagination link
        return await fetchViaLink(pageParam, params);
      } else if (stacApi) {
        // Initial search via StacApi
        const response = await stacApi.search({
          ...params,
          dateRange: params.datetime ? parseDateTime(params.datetime) : undefined,
        }, headers);
        
        if (!response.ok) {
          throw new ApiError(
            response.statusText,
            response.status,
            await response.text(),
            response.url
          );
        }
        
        return response.json();
      } else {
        throw new Error('Either provide stacApi context or searchLink');
      }
    },
    
    initialPageParam: searchLink,
    
    getNextPageParam: (lastPage: SearchResponse) => {
      return lastPage.links?.find(link => link.rel === 'next');
    },
    
    getPreviousPageParam: (firstPage: SearchResponse) => {
      return firstPage.links?.find(link => 
        ['prev', 'previous'].includes(link.rel)
      );
    },
    
    enabled: enabled && (!!stacApi || !!searchLink),
    retry: false,
  });
  
  // Flatten all items from all pages
  const items = useMemo(() => {
    return query.data?.pages.flatMap(page => page.features) ?? [];
  }, [query.data]);
  
  return {
    data: query.data,
    items,
    fetchNextPage: query.fetchNextPage,
    fetchPreviousPage: query.fetchPreviousPage,
    hasNextPage: query.hasNextPage,
    hasPreviousPage: query.hasPreviousPage,
    isLoading: query.isLoading,
    isFetchingNextPage: query.isFetchingNextPage,
    isFetchingPreviousPage: query.isFetchingPreviousPage,
    error: query.error,
    refetch: query.refetch,
  };
}

Example Usage Patterns

1. Infinite Scroll

function InfiniteScrollResults() {
  const {
    items,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useStacSearchInfinite({
    params: { collections: ['sentinel-2'], limit: 25 },
  });
  
  const observerRef = useRef();
  
  // Intersection observer for automatic loading
  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
          fetchNextPage();
        }
      },
      { threshold: 0.1 }
    );
    
    if (observerRef.current) {
      observer.observe(observerRef.current);
    }
    
    return () => observer.disconnect();
  }, [hasNextPage, isFetchingNextPage, fetchNextPage]);
  
  return (
    <div>
      <ItemsGrid items={items} />
      <div ref={observerRef} style={{ height: 20 }} />
      {isFetchingNextPage && <LoadingSpinner />}
    </div>
  );
}

2. Load More Button

function LoadMoreResults() {
  const {
    items,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useStacSearchInfinite({
    params: { bbox: [-180, -90, 180, 90], limit: 50 },
  });
  
  return (
    <div>
      <ItemsGrid items={items} />
      <p>Showing {items.length} items</p>
      
      {hasNextPage && (
        <button 
          onClick={() => fetchNextPage()} 
          disabled={isFetchingNextPage}
        >
          {isFetchingNextPage ? 'Loading...' : 'Load More'}
        </button>
      )}
    </div>
  );
}

3. Virtualized List

import { useVirtualizer } from '@tanstack/react-virtual';

function VirtualizedResults() {
  const { items, fetchNextPage, hasNextPage } = useStacSearchInfinite({
    params: { collections: ['landsat-8'] },
  });
  
  const parentRef = useRef<HTMLDivElement>(null);
  
  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 200,
    overscan: 5,
  });
  
  // Load more when near end
  useEffect(() => {
    const lastItem = virtualizer.getVirtualItems().at(-1);
    
    if (lastItem && lastItem.index >= items.length - 1 && hasNextPage) {
      fetchNextPage();
    }
  }, [virtualizer.getVirtualItems(), fetchNextPage, hasNextPage, items.length]);
  
  return (
    <div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
      <div style={{ height: virtualizer.getTotalSize() }}>
        {virtualizer.getVirtualItems().map(item => (
          <div
            key={item.key}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              transform: `translateY(${item.start}px)`,
            }}
          >
            <ItemCard item={items[item.index]} />
          </div>
        ))}
      </div>
    </div>
  );
}

4. Page Count Display

function ResultsWithPageInfo() {
  const { data, items, fetchNextPage, hasNextPage } = useStacSearchInfinite({
    params: { collections: ['sentinel-1'] },
  });
  
  const pageCount = data?.pages.length ?? 0;
  
  return (
    <div>
      <p>
        Showing {items.length} items across {pageCount} pages
      </p>
      <ItemsGrid items={items} />
      {hasNextPage && (
        <button onClick={() => fetchNextPage()}>Load Page {pageCount + 1}</button>
      )}
    </div>
  );
}

Collections Infinite Query

Similarly, add infinite query support for collections:

function useCollectionsInfinite(
  collectionsUrl?: string
): UseInfiniteQueryResult<CollectionsResponse> {
  return useInfiniteQuery({
    queryKey: ['stac-collections-infinite', collectionsUrl],
    
    queryFn: async ({ pageParam }) => {
      const url = pageParam || collectionsUrl;
      if (!url) throw new Error('No collections URL provided');
      
      const response = await fetch(url);
      if (!response.ok) {
        throw new Error(`Failed to fetch collections: ${response.statusText}`);
      }
      
      return response.json();
    },
    
    initialPageParam: collectionsUrl,
    
    getNextPageParam: (lastPage: CollectionsResponse) => {
      return lastPage.links?.find(link => link.rel === 'next')?.href;
    },
    
    enabled: !!collectionsUrl,
  });
}

Benefits

  • ✅ Support infinite scroll patterns
  • ✅ "Load more" functionality
  • ✅ Accumulate results across pages
  • ✅ Better UX for large result sets
  • ✅ Automatic pagination via intersection observer
  • ✅ Works with virtualized lists
  • ✅ Maintains all loaded data in cache

Performance Considerations

  • React Query manages all pages efficiently
  • Each page is cached separately
  • Refetching only updates stale pages
  • Virtual scrolling recommended for very large result sets

Breaking Changes

None - this is a new hook alongside existing pagination.

Testing Requirements

  • Test initial page load
  • Test fetching next page
  • Test fetching previous page (if supported)
  • Test items flattening across pages
  • Test with link-based pagination
  • Test with token-based pagination
  • Test hasNextPage detection
  • Test error handling across pages
  • Test refetch behavior

Documentation Requirements

  • Document infinite scroll pattern
  • Show load more button example
  • Document intersection observer usage
  • Show virtualized list integration
  • Document performance best practices
  • Show page count tracking
  • Document when to use infinite vs standard pagination

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions