developmentseed / developmentseed/stac-react

Add Infinite Query Support for Pagination

Open
#39 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
34
Forks
4
PR merge metrics
No merged PRs in 30d

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
```typescript
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
```typescript
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 (



{hasNextPage && (

{isFetchingNextPage ? 'Loading...' : 'Load More'}

)}

);
}
```

## Use Cases from stac-map

stac-map uses `useInfiniteQuery` for both collections and search:

```typescript
// 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
```typescript
type UseStacSearchInfiniteOptions = {
/** Search parameters */
params: StacSearchParams;

/** Optional: specific search link to use */
searchLink?: Link;

/** Enable/disable search */
enabled?: boolean;

/** Custom headers */
headers?: Record;
};

type UseStacSearchInfiniteResult = {
/** All pages of results */
data?: {
pages: SearchResponse[];
pageParams: (Link | undefined)[];
};

/** All items flattened */
items?: Item[];

/** Fetch next page */
fetchNextPage: () => Promise;

/** Fetch previous page (if supported) */
fetchPreviousPage?: () => Promise;

/** 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;
};

function useStacSearchInfinite(
options: UseStacSearchInfiniteOptions
): UseStacSearchInfiniteResult;
```

### Implementation
```typescript
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
```typescript
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 (




{isFetchingNextPage && }

);
}
```

#### 2. Load More Button
```typescript
function LoadMoreResults() {
const {
items,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useStacSearchInfinite({
params: { bbox: [-180, -90, 180, 90], limit: 50 },
});

return (



Showing {items.length} items



{hasNextPage && (
fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading...' : 'Load More'}

)}

);
}
```

#### 3. Virtualized List
```typescript
import { useVirtualizer } from '@tanstack/react-virtual';

function VirtualizedResults() {
const { items, fetchNextPage, hasNextPage } = useStacSearchInfinite({
params: { collections: ['landsat-8'] },
});

const parentRef = useRef(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 (



{virtualizer.getVirtualItems().map(item => (



))}


);
}
```

#### 4. Page Count Display
```typescript
function ResultsWithPageInfo() {
const { data, items, fetchNextPage, hasNextPage } = useStacSearchInfinite({
params: { collections: ['sentinel-1'] },
});

const pageCount = data?.pages.length ?? 0;

return (



Showing {items.length} items across {pageCount} pages



{hasNextPage && (
fetchNextPage()}>Load Page {pageCount + 1}
)}

);
}
```

## Collections Infinite Query

Similarly, add infinite query support for collections:

```typescript
function useCollectionsInfinite(
collectionsUrl?: string
): UseInfiniteQueryResult {
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

Contributor guide

No contributing guide indexed for this repository

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.