Pagination Patterns Guide
Learn how to implement efficient pagination patterns for large datasets using GraphQL cursor-based pagination, infinite scrolling, and optimized data fetching strategies.
Prerequisites
- Understanding of GraphQL basics
- Familiarity with React or your preferred frontend framework
- Basic knowledge of database concepts
- Unified Commerce API access
Table of Contents
- Pagination Overview
- Cursor-Based Pagination
- Offset Pagination
- Infinite Scrolling
- Load More Pattern
- Hybrid Approaches
- Performance Optimization
- Search & Filter Integration
- Real-World Examples
- Troubleshooting
Pagination Overview
Why Cursor-Based Pagination?
Unified Commerce uses cursor-based pagination (Relay specification) because it:
- Handles real-time data - No issues with items being added/removed
- Scales efficiently - O(1) database queries regardless of page depth
- Provides stable results - Cursors remain valid even as data changes
- Enables bidirectional navigation - Move forward and backward through results
Pagination Schema
# Connection type following Relay specification
type ProductConnection {
edges: [ProductEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type ProductEdge {
cursor: String!
node: Product!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
# Query with pagination arguments
type Query {
products(
first: Int
after: String
last: Int
before: String
filters: ProductFilterInput
sortBy: ProductSortField
sortOrder: SortOrder
): ProductConnection!
}
Cursor-Based Pagination
Basic Implementation
// hooks/use-products.ts
import { gql, useQuery } from '@apollo/client';
const GET_PRODUCTS = gql`
query GetProducts(
$first: Int!
$after: String
$filters: ProductFilterInput
$sortBy: ProductSortField
$sortOrder: SortOrder
) {
products(
first: $first
after: $after
filters: $filters
sortBy: $sortBy
sortOrder: $sortOrder
) {
edges {
cursor
node {
id
name
slug
price
currency
images(first: 1) {
url
alt
}
inventory {
available
}
}
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
totalCount
}
}
`;
export function useProducts(options: ProductQueryOptions = {}) {
const {
pageSize = 20,
filters = {},
sortBy = 'CREATED_AT',
sortOrder = 'DESC',
} = options;
const { data, loading, error, fetchMore, refetch } = useQuery(GET_PRODUCTS, {
variables: {
first: pageSize,
after: null,
filters,
sortBy,
sortOrder,
},
notifyOnNetworkStatusChange: true,
});
const loadMore = async () => {
if (!data?.products?.pageInfo?.hasNextPage) {
return;
}
await fetchMore({
variables: {
after: data.products.pageInfo.endCursor,
},
updateQuery: (previousResult, { fetchMoreResult }) => {
if (!fetchMoreResult) return previousResult;
return {
products: {
...fetchMoreResult.products,
edges: [
...previousResult.products.edges,
...fetchMoreResult.products.edges,
],
pageInfo: fetchMoreResult.products.pageInfo,
},
};
},
});
};
return {
products: data?.products?.edges?.map(edge => edge.node) || [],
pageInfo: data?.products?.pageInfo,
totalCount: data?.products?.totalCount || 0,
loading,
error,
loadMore,
hasMore: data?.products?.pageInfo?.hasNextPage || false,
refetch,
};
}
Bidirectional Navigation
// components/paginated-list.tsx
import { useState } from 'react';
import { gql, useQuery } from '@apollo/client';
interface PaginationState {
first?: number;
after?: string;
last?: number;
before?: string;
}
export function PaginatedProductList() {
const [paginationState, setPaginationState] = useState<PaginationState>({
first: 20,
});
const { data, loading, error } = useQuery(GET_PRODUCTS, {
variables: paginationState,
});
const goToNextPage = () => {
setPaginationState({
first: 20,
after: data?.products?.pageInfo?.endCursor,
});
};
const goToPreviousPage = () => {
setPaginationState({
last: 20,
before: data?.products?.pageInfo?.startCursor,
});
};
const goToFirstPage = () => {
setPaginationState({
first: 20,
});
};
if (loading) return <LoadingSpinner />;
if (error) return <ErrorMessage error={error} />;
const products = data?.products?.edges?.map(e => e.node) || [];
const { hasNextPage, hasPreviousPage } = data?.products?.pageInfo || {};
return (
<div>
<ProductGrid products={products} />
<div className="pagination-controls">
<button
onClick={goToFirstPage}
disabled={!hasPreviousPage}
className="btn btn-secondary"
>
First
</button>
<button
onClick={goToPreviousPage}
disabled={!hasPreviousPage}
className="btn btn-secondary"
>
Previous
</button>
<span className="page-info">
Showing {products.length} of {data?.products?.totalCount} products
</span>
<button
onClick={goToNextPage}
disabled={!hasNextPage}
className="btn btn-secondary"
>
Next
</button>
</div>
</div>
);
}
Cursor Management
// utils/cursor-manager.ts
export class CursorManager {
private history: string[] = [];
private currentIndex: number = -1;
push(cursor: string) {
// Remove any forward history when navigating to a new page
this.history = this.history.slice(0, this.currentIndex + 1);
this.history.push(cursor);
this.currentIndex++;
}
back(): string | null {
if (this.currentIndex > 0) {
this.currentIndex--;
return this.history[this.currentIndex];
}
return null;
}
forward(): string | null {
if (this.currentIndex < this.history.length - 1) {
this.currentIndex++;
return this.history[this.currentIndex];
}
return null;
}
reset() {
this.history = [];
this.currentIndex = -1;
}
get canGoBack(): boolean {
return this.currentIndex > 0;
}
get canGoForward(): boolean {
return this.currentIndex < this.history.length - 1;
}
get current(): string | null {
return this.history[this.currentIndex] || null;
}
}
// Usage in component
export function NavigableProductList() {
const [cursorManager] = useState(() => new CursorManager());
const [currentCursor, setCurrentCursor] = useState<string | null>(null);
const { data, loading, refetch } = useQuery(GET_PRODUCTS, {
variables: {
first: 20,
after: currentCursor,
},
});
const navigate = (direction: 'forward' | 'back' | 'next') => {
let newCursor: string | null = null;
switch (direction) {
case 'next':
newCursor = data?.products?.pageInfo?.endCursor || null;
if (newCursor) {
cursorManager.push(newCursor);
}
break;
case 'back':
newCursor = cursorManager.back();
break;
case 'forward':
newCursor = cursorManager.forward();
break;
}
setCurrentCursor(newCursor);
};
return (
// Component JSX
);
}
Offset Pagination
While Unified Commerce primarily uses cursor-based pagination, offset pagination can be implemented for specific use cases:
// Offset pagination wrapper
export function useOffsetPagination(query: DocumentNode, options: Options = {}) {
const [page, setPage] = useState(1);
const pageSize = options.pageSize || 20;
const { data, loading, error } = useQuery(query, {
variables: {
offset: (page - 1) * pageSize,
limit: pageSize,
...options.variables,
},
});
const totalPages = Math.ceil((data?.totalCount || 0) / pageSize);
return {
data: data?.items || [],
loading,
error,
page,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
goToPage: setPage,
nextPage: () => setPage(p => Math.min(p + 1, totalPages)),
previousPage: () => setPage(p => Math.max(p - 1, 1)),
};
}
// Convert cursor to offset (approximation)
export function estimateOffsetFromCursor(
cursor: string,
pageSize: number
): number {
// Decode cursor (assuming base64 encoded index)
try {
const decoded = atob(cursor);
const index = parseInt(decoded.split(':')[1], 10);
return Math.floor(index / pageSize) + 1;
} catch {
return 1;
}
}
Infinite Scrolling
React Implementation
// hooks/use-infinite-scroll.ts
import { useEffect, useRef, useCallback } from 'react';
import { useInView } from 'react-intersection-observer';
export function useInfiniteScroll(
loadMore: () => Promise<void>,
hasMore: boolean,
loading: boolean
) {
const { ref, inView } = useInView({
threshold: 0.1,
rootMargin: '100px',
});
useEffect(() => {
if (inView && hasMore && !loading) {
loadMore();
}
}, [inView, hasMore, loading, loadMore]);
return { ref };
}
// Component using infinite scroll
export function InfiniteProductList() {
const { products, loading, hasMore, loadMore } = useProducts();
const { ref } = useInfiniteScroll(loadMore, hasMore, loading);
return (
<div className="infinite-scroll-container">
<div className="product-grid">
{products.map((product, index) => (
<ProductCard
key={product.id}
product={product}
// Attach ref to trigger element (3 items before end)
ref={index === products.length - 3 ? ref : undefined}
/>
))}
</div>
{loading && <LoadingSpinner />}
{!hasMore && products.length > 0 && (
<div className="end-message">
You've reached the end! {products.length} products loaded.
</div>
)}
</div>
);
}
Virtual Scrolling for Large Lists
// components/virtual-product-list.tsx
import { FixedSizeList } from 'react-window';
import AutoSizer from 'react-virtualized-auto-sizer';
export function VirtualProductList() {
const { products, loadMore, hasMore } = useProducts({ pageSize: 50 });
const loadMoreItems = useCallback(
(startIndex: number, stopIndex: number) => {
if (hasMore && stopIndex >= products.length - 10) {
return loadMore();
}
return Promise.resolve();
},
[hasMore, loadMore, products.length]
);
const Row = ({ index, style }: { index: number; style: any }) => {
const product = products[index];
if (!product) {
return (
<div style={style}>
<LoadingPlaceholder />
</div>
);
}
return (
<div style={style}>
<ProductRow product={product} />
</div>
);
};
return (
<AutoSizer>
{({ height, width }) => (
<FixedSizeList
height={height}
width={width}
itemCount={hasMore ? products.length + 10 : products.length}
itemSize={120}
onItemsRendered={({ visibleStopIndex }) => {
if (visibleStopIndex >= products.length - 10) {
loadMoreItems(0, visibleStopIndex);
}
}}
>
{Row}
</FixedSizeList>
)}
</AutoSizer>
);
}
Load More Pattern
Manual Load More Button
// components/load-more-list.tsx
export function LoadMoreProductList() {
const [products, setProducts] = useState<Product[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(false);
const [initialLoading, setInitialLoading] = useState(true);
const loadProducts = useCallback(async (after?: string | null) => {
setLoading(true);
try {
const { data } = await client.query({
query: GET_PRODUCTS,
variables: {
first: 20,
after,
},
});
const newProducts = data.products.edges.map(e => e.node);
if (after) {
setProducts(prev => [...prev, ...newProducts]);
} else {
setProducts(newProducts);
}
setCursor(data.products.pageInfo.endCursor);
setHasMore(data.products.pageInfo.hasNextPage);
} catch (error) {
console.error('Failed to load products:', error);
} finally {
setLoading(false);
setInitialLoading(false);
}
}, []);
useEffect(() => {
loadProducts();
}, []);
if (initialLoading) {
return <LoadingGrid />;
}
return (
<div className="load-more-container">
<ProductGrid products={products} />
{hasMore && (
<div className="load-more-wrapper">
<button
onClick={() => loadProducts(cursor)}
disabled={loading}
className="load-more-btn"
>
{loading ? (
<>
<Spinner /> Loading...
</>
) : (
'Load More Products'
)}
</button>
<p className="text-muted">
Showing {products.length} products
</p>
</div>
)}
{!hasMore && products.length > 0 && (
<p className="end-message">
All {products.length} products loaded
</p>
)}
</div>
);
}
Progressive Loading
// components/progressive-list.tsx
export function ProgressiveProductList() {
const [loadedPages, setLoadedPages] = useState(1);
const [allProducts, setAllProducts] = useState<Product[]>([]);
const itemsPerPage = 20;
const {
products,
pageInfo,
loading,
fetchMore,
} = useProducts({
pageSize: itemsPerPage * 3, // Load 3 pages at once
});
// Show products progressively
const visibleProducts = allProducts.slice(0, loadedPages * itemsPerPage);
const showMore = () => {
const totalLoaded = Math.floor(allProducts.length / itemsPerPage);
if (loadedPages < totalLoaded) {
// Show more already loaded products
setLoadedPages(prev => prev + 1);
} else if (pageInfo?.hasNextPage) {
// Fetch more from server
fetchMore({
variables: {
after: pageInfo.endCursor,
},
});
}
};
useEffect(() => {
setAllProducts(products);
}, [products]);
return (
<div>
<ProductGrid products={visibleProducts} />
<div className="progress-info">
<p>
Showing {visibleProducts.length} of {allProducts.length} loaded
{pageInfo?.hasNextPage && ' (more available)'}
</p>
<ProgressBar
value={visibleProducts.length}
max={allProducts.length}
/>
{(loadedPages * itemsPerPage < allProducts.length || pageInfo?.hasNextPage) && (
<button onClick={showMore} disabled={loading}>
Show More
</button>
)}
</div>
</div>
);
}
Hybrid Approaches
Pagination with Infinite Scroll
// components/hybrid-pagination.tsx
export function HybridPagination() {
const [mode, setMode] = useState<'paginated' | 'infinite'>('paginated');
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 24;
const {
products,
pageInfo,
totalCount,
loading,
loadMore,
refetch,
} = useProducts({ pageSize });
const totalPages = Math.ceil(totalCount / pageSize);
// Switch between modes
const toggleMode = () => {
setMode(prev => prev === 'paginated' ? 'infinite' : 'paginated');
};
if (mode === 'infinite') {
return (
<div>
<button onClick={toggleMode}>Switch to Pages</button>
<InfiniteProductList />
</div>
);
}
return (
<div>
<button onClick={toggleMode}>Switch to Infinite Scroll</button>
<ProductGrid products={products} />
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={(page) => {
setCurrentPage(page);
// Calculate cursor for page
refetch({
first: pageSize,
after: calculateCursorForPage(page, pageSize),
});
}}
/>
</div>
);
}
Smart Pagination
// components/smart-pagination.tsx
export function SmartPagination() {
const [strategy, setStrategy] = useState<'cursor' | 'numbered' | 'infinite'>('cursor');
const deviceType = useDeviceType(); // mobile, tablet, desktop
// Auto-select strategy based on context
useEffect(() => {
if (deviceType === 'mobile') {
setStrategy('infinite');
} else if (totalCount > 1000) {
setStrategy('cursor'); // Better for large datasets
} else {
setStrategy('numbered'); // Better UX for small datasets
}
}, [deviceType, totalCount]);
switch (strategy) {
case 'infinite':
return <InfiniteScrollProducts />;
case 'numbered':
return <NumberedPagination totalItems={totalCount} />;
case 'cursor':
default:
return <CursorPagination />;
}
}
Performance Optimization
Prefetching Next Page
// hooks/use-prefetch-pagination.ts
export function usePrefetchPagination() {
const cache = useApolloClient();
const prefetchNextPage = useCallback(
async (cursor: string) => {
await cache.query({
query: GET_PRODUCTS,
variables: {
first: 20,
after: cursor,
},
});
},
[cache]
);
const { data, loading, fetchMore } = useQuery(GET_PRODUCTS, {
variables: { first: 20 },
onCompleted: (data) => {
// Prefetch next page when current page loads
if (data?.products?.pageInfo?.endCursor) {
prefetchNextPage(data.products.pageInfo.endCursor);
}
},
});
return {
products: data?.products,
loading,
fetchMore,
prefetchNextPage,
};
}
Optimistic Pagination
// utils/optimistic-pagination.ts
export function optimisticPaginationUpdate(
cache: ApolloCache<any>,
queryName: string,
newItem: any
) {
const existing = cache.readQuery({
query: GET_PRODUCTS,
variables: { first: 20 },
});
if (existing) {
cache.writeQuery({
query: GET_PRODUCTS,
variables: { first: 20 },
data: {
...existing,
[queryName]: {
...existing[queryName],
edges: [
{
__typename: 'ProductEdge',
cursor: generateTempCursor(),
node: newItem,
},
...existing[queryName].edges,
],
totalCount: existing[queryName].totalCount + 1,
},
},
});
}
}
function generateTempCursor(): string {
return btoa(`temp:${Date.now()}`);
}
Debounced Loading
// hooks/use-debounced-pagination.ts
export function useDebouncedPagination(delay: number = 300) {
const [debouncedLoad] = useDebouncedCallback(
(loadFn: () => void) => {
loadFn();
},
delay
);
return {
loadMore: useCallback((originalLoad: () => void) => {
debouncedLoad(originalLoad);
}, [debouncedLoad]),
};
}
Search & Filter Integration
Paginated Search Results
// components/search-results.tsx
export function SearchResults() {
const [searchTerm, setSearchTerm] = useState('');
const [filters, setFilters] = useState<FilterOptions>({});
const [sortBy, setSortBy] = useState('RELEVANCE');
const {
data,
loading,
error,
fetchMore,
refetch,
} = useQuery(SEARCH_PRODUCTS, {
variables: {
searchTerm,
filters,
sortBy,
first: 20,
},
skip: !searchTerm,
});
// Reset pagination on search/filter change
useEffect(() => {
refetch({
searchTerm,
filters,
sortBy,
first: 20,
after: null,
});
}, [searchTerm, filters, sortBy]);
const handleLoadMore = async () => {
await fetchMore({
variables: {
after: data?.searchProducts?.pageInfo?.endCursor,
},
});
};
return (
<div>
<SearchBar
value={searchTerm}
onChange={setSearchTerm}
onSearch={() => refetch()}
/>
<FilterPanel
filters={filters}
onChange={setFilters}
availableFacets={data?.searchProducts?.facets}
/>
<SortDropdown
value={sortBy}
onChange={setSortBy}
options={['RELEVANCE', 'PRICE_ASC', 'PRICE_DESC', 'NEWEST']}
/>
<SearchResultsList
results={data?.searchProducts?.edges || []}
loading={loading}
onLoadMore={handleLoadMore}
hasMore={data?.searchProducts?.pageInfo?.hasNextPage}
/>
<SearchPagination
pageInfo={data?.searchProducts?.pageInfo}
totalCount={data?.searchProducts?.totalCount}
/>
</div>
);
}
Faceted Search with Pagination
// components/faceted-search.tsx
interface FacetedSearchProps {
category?: string;
initialFilters?: FilterOptions;
}
export function FacetedSearch({ category, initialFilters }: FacetedSearchProps) {
const [appliedFilters, setAppliedFilters] = useState(initialFilters || {});
const [pendingFilters, setPendingFilters] = useState(initialFilters || {});
const [isFilterOpen, setIsFilterOpen] = useState(false);
const {
products,
facets,
loading,
pageInfo,
refetch,
fetchMore,
} = useFacetedSearch({
category,
filters: appliedFilters,
});
const applyFilters = () => {
setAppliedFilters(pendingFilters);
setIsFilterOpen(false);
// Reset to first page
refetch({
filters: pendingFilters,
first: 20,
after: null,
});
};
const clearFilters = () => {
setPendingFilters({});
setAppliedFilters({});
refetch({
filters: {},
first: 20,
after: null,
});
};
return (
<div className="faceted-search">
<div className="filter-sidebar">
<h3>Filters</h3>
{facets.map(facet => (
<FacetGroup
key={facet.name}
facet={facet}
selected={pendingFilters[facet.name]}
onChange={(values) => {
setPendingFilters({
...pendingFilters,
[facet.name]: values,
});
}}
/>
))}
<button onClick={applyFilters}>Apply Filters</button>
<button onClick={clearFilters}>Clear All</button>
</div>
<div className="results">
<div className="results-header">
<p>{products.totalCount} results</p>
<ActiveFilters
filters={appliedFilters}
onRemove={(key) => {
const newFilters = { ...appliedFilters };
delete newFilters[key];
setAppliedFilters(newFilters);
refetch({ filters: newFilters });
}}
/>
</div>
<ProductGrid products={products} />
<LoadMoreButton
onClick={() => fetchMore({ after: pageInfo.endCursor })}
hasMore={pageInfo.hasNextPage}
loading={loading}
/>
</div>
</div>
);
}
Real-World Examples
E-commerce Product Catalog
// pages/products.tsx
export function ProductCatalogPage() {
const router = useRouter();
const { category, page, sort, ...filters } = router.query;
const {
products,
totalCount,
pageInfo,
loading,
error,
} = useProducts({
category: category as string,
filters: parseFilters(filters),
sortBy: parseSortBy(sort as string),
page: parseInt(page as string) || 1,
});
const updateURL = (params: URLSearchParams) => {
router.push({
pathname: router.pathname,
query: params.toString(),
}, undefined, { shallow: true });
};
const handlePageChange = (newPage: number) => {
const params = new URLSearchParams(router.query as any);
params.set('page', newPage.toString());
updateURL(params);
};
const handleFilterChange = (newFilters: FilterOptions) => {
const params = new URLSearchParams();
params.set('category', category as string);
Object.entries(newFilters).forEach(([key, value]) => {
if (value) {
params.set(key, Array.isArray(value) ? value.join(',') : value);
}
});
params.delete('page'); // Reset to page 1
updateURL(params);
};
return (
<Layout>
<div className="catalog-page">
<Breadcrumbs category={category as string} />
<div className="catalog-header">
<h1>{category || 'All Products'}</h1>
<p>{totalCount} products</p>
</div>
<div className="catalog-body">
<FilterSidebar
filters={filters}
onChange={handleFilterChange}
/>
<div className="catalog-main">
<SortBar
value={sort as string}
onChange={(newSort) => {
const params = new URLSearchParams(router.query as any);
params.set('sort', newSort);
updateURL(params);
}}
/>
{loading && <LoadingGrid />}
{error && <ErrorMessage error={error} />}
<ProductGrid products={products} />
<Pagination
currentPage={parseInt(page as string) || 1}
totalPages={Math.ceil(totalCount / 20)}
onPageChange={handlePageChange}
/>
</div>
</div>
</div>
</Layout>
);
}
Order History
// components/order-history.tsx
export function OrderHistory({ customerId }: { customerId: string }) {
const [timeRange, setTimeRange] = useState('LAST_30_DAYS');
const [status, setStatus] = useState<OrderStatus | null>(null);
const {
orders,
loading,
hasMore,
loadMore,
refetch,
} = useOrders({
customerId,
timeRange,
status,
});
// Group orders by date
const groupedOrders = useMemo(() => {
return orders.reduce((groups, order) => {
const date = format(new Date(order.createdAt), 'MMMM yyyy');
if (!groups[date]) {
groups[date] = [];
}
groups[date].push(order);
return groups;
}, {} as Record<string, Order[]>);
}, [orders]);
return (
<div className="order-history">
<div className="filters">
<TimeRangeSelect
value={timeRange}
onChange={(value) => {
setTimeRange(value);
refetch();
}}
/>
<OrderStatusSelect
value={status}
onChange={(value) => {
setStatus(value);
refetch();
}}
/>
</div>
{Object.entries(groupedOrders).map(([date, dateOrders]) => (
<div key={date} className="order-group">
<h3>{date}</h3>
{dateOrders.map(order => (
<OrderCard key={order.id} order={order} />
))}
</div>
))}
{loading && <LoadingSpinner />}
{hasMore && (
<button onClick={loadMore} className="load-more">
Load More Orders
</button>
)}
{!loading && orders.length === 0 && (
<EmptyState message="No orders found" />
)}
</div>
);
}
Troubleshooting
Common Issues
1. Duplicate Items
Problem: Same items appear multiple times Solution:
// Deduplicate by ID
const deduplicateEdges = (edges: Edge[]) => {
const seen = new Set();
return edges.filter(edge => {
if (seen.has(edge.node.id)) {
return false;
}
seen.add(edge.node.id);
return true;
});
};
// In updateQuery
updateQuery: (prev, { fetchMoreResult }) => ({
products: {
...fetchMoreResult.products,
edges: deduplicateEdges([
...prev.products.edges,
...fetchMoreResult.products.edges,
]),
},
});
2. Lost Scroll Position
Problem: Scroll jumps when loading more Solution:
// Preserve scroll position
const preserveScrollPosition = () => {
const scrollY = window.scrollY;
return () => {
requestAnimationFrame(() => {
window.scrollTo(0, scrollY);
});
};
};
// Use when loading
const restoreScroll = preserveScrollPosition();
await loadMore();
restoreScroll();
3. Stale Pagination State
Problem: Page info outdated after mutations Solution:
// Invalidate pagination cache
const handleDelete = async (id: string) => {
await deleteItem(id);
// Refetch with fresh pagination
await refetch({
first: pageSize,
after: null, // Reset to first page
});
};
4. Memory Leaks with Large Lists
Problem: Browser slows down with many items Solution:
// Implement windowing
const WINDOW_SIZE = 100;
const windowedProducts = products.slice(
Math.max(0, currentIndex - WINDOW_SIZE / 2),
currentIndex + WINDOW_SIZE / 2
);
Best Practices
- Always provide loading states for better UX
- Implement error boundaries for graceful failures
- Use virtual scrolling for lists with 1000+ items
- Cache page data appropriately
- Provide keyboard navigation for accessibility
- Show total count when available
- Reset pagination when filters change
- Prefetch next page for better performance
- Use debouncing for rapid navigation
- Test with slow connections to ensure good UX