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
Documentation Requirements
Problem
Current pagination in
useStacSearchuses next/previous page functions that replace the current results. This doesn't support:Modern STAC viewers need infinite scroll to display large result sets without traditional pagination controls.
Current Behavior
Desired Behavior
Use Cases from stac-map
stac-map uses
useInfiniteQueryfor both collections and search:Proposed Solution
Hook Signature
Implementation
Example Usage Patterns
1. Infinite Scroll
2. Load More Button
3. Virtualized List
4. Page Count Display
Collections Infinite Query
Similarly, add infinite query support for collections:
Benefits
Performance Considerations
Breaking Changes
None - this is a new hook alongside existing pagination.
Testing Requirements
Documentation Requirements