Add Infinite Query Support for Pagination

Open
#39 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Assessment

Difficulty
5/5
Estimated time
Over a week
Newbie friendliness
35/100
Issue type
Feature
Clarity
Mostly clear
Activity status
Stale
Tech stack
react, typescript
Domain
api, frontend

Research direction

Start by locating the useStacSearch entry point and reviewing how pagination currently replaces results. Then assess the proposed useInfiniteQuery API, page accumulation, link-based navigation, and the testing and documentation requirements; done means the listed search and collections behaviors are covered without changing existing pagination.

Written by the indexing model from the issue text.

Description

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
Dominant language
TypeScript
Stars
34
Forks
4
PR merge metrics
No merged PRs in 30d

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from developmentseed/stac-react

All issues in developmentseed/stac-react

Similar issues

More TypeScript issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.