-
Notifications
You must be signed in to change notification settings - Fork 4
Open
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 scrollDesired 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
Labels
No labels