PatternsData Fetching Strategies
Pagination Strategies
Efficient patterns for loading large datasets
Loading large datasets efficiently is critical for performance. This guide covers proven pagination patterns.
🎯 Goals
What we want:
✅ Fast initial load
✅ Smooth user experience
✅ Efficient network usage
✅ SEO-friendly (when needed)
✅ Handle millions of items📄 Pattern 1: Offset-Based Pagination
Traditional page numbers (1, 2, 3...).
interface PaginationParams {
page: number;
pageSize: number;
}
interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
async function fetchProducts(
params: PaginationParams
): Promise<PaginatedResponse<Product>> {
const offset = (params.page - 1) * params.pageSize;
const response = await fetch(
`/api/products?offset=${offset}&limit=${params.pageSize}`
);
return response.json();
}
// Usage
const page1 = await fetchProducts({ page: 1, pageSize: 20 });
// Returns items 0-19
const page2 = await fetchProducts({ page: 2, pageSize: 20 });
// Returns items 20-39React Component
function ProductList() {
const [page, setPage] = useState(1);
const pageSize = 20;
const { data, loading } = useQuery({
queryKey: ['products', page, pageSize],
queryFn: () => fetchProducts({ page, pageSize })
});
if (loading) return <div>Loading...</div>;
return (
<div>
{data?.data.map(product => (
<ProductCard key={product.id} product={product} />
))}
<div className="pagination">
<button
onClick={() => setPage(p => p - 1)}
disabled={page === 1}
>
Previous
</button>
<span>Page {page} of {data?.totalPages}</span>
<button
onClick={() => setPage(p => p + 1)}
disabled={page === data?.totalPages}
>
Next
</button>
</div>
</div>
);
}Pros:
- ✅ Simple to implement
- ✅ Jump to any page
- ✅ Show total pages
- ✅ SEO-friendly URLs
Cons:
- ⚠️ Slow for large offsets (SELECT * FROM ... OFFSET 1000000)
- ⚠️ Inconsistent when data changes (items can skip/duplicate)
- ⚠️ Not suitable for real-time data
🔄 Pattern 2: Cursor-Based Pagination
Use a cursor (pointer) to next items.
interface CursorPaginationParams {
cursor?: string;
limit: number;
}
interface CursorPaginatedResponse<T> {
data: T[];
nextCursor: string | null;
hasMore: boolean;
}
async function fetchProducts(
params: CursorPaginationParams
): Promise<CursorPaginatedResponse<Product>> {
const url = params.cursor
? `/api/products?cursor=${params.cursor}&limit=${params.limit}`
: `/api/products?limit=${params.limit}`;
const response = await fetch(url);
return response.json();
}
// Backend implementation
app.get('/api/products', async (req, res) => {
const limit = parseInt(req.query.limit) || 20;
const cursor = req.query.cursor;
let query = 'SELECT * FROM products';
if (cursor) {
// Cursor is encoded product ID
query += ` WHERE id > '${cursor}'`;
}
query += ` ORDER BY id ASC LIMIT ${limit + 1}`;
const products = await db.query(query);
const hasMore = products.length > limit;
const data = hasMore ? products.slice(0, -1) : products;
const nextCursor = hasMore ? data[data.length - 1].id : null;
res.json({
data,
nextCursor,
hasMore
});
});React Component
function ProductList() {
const [cursor, setCursor] = useState<string | null>(null);
const [products, setProducts] = useState<Product[]>([]);
const { data, loading } = useQuery({
queryKey: ['products', cursor],
queryFn: () => fetchProducts({ cursor: cursor || undefined, limit: 20 })
});
useEffect(() => {
if (data) {
setProducts(prev => [...prev, ...data.data]);
}
}, [data]);
return (
<div>
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
{data?.hasMore && (
<button
onClick={() => setCursor(data.nextCursor)}
disabled={loading}
>
Load More
</button>
)}
</div>
);
}Pros:
- ✅ Fast regardless of position
- ✅ Consistent (no skips/duplicates)
- ✅ Works with real-time data
- ✅ Efficient database queries
Cons:
- ⚠️ Can't jump to arbitrary page
- ⚠️ Harder to implement
- ⚠️ Less SEO-friendly
♾️ Pattern 3: Infinite Scroll
Load more as user scrolls.
import { useInfiniteQuery } from '@tanstack/react-query';
import { useInView } from 'react-intersection-observer';
function InfiniteProductList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['products'],
queryFn: ({ pageParam = null }) =>
fetchProducts({ cursor: pageParam, limit: 20 }),
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
const { ref, inView } = useInView();
// Load more when sentinel comes into view
useEffect(() => {
if (inView && hasNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, fetchNextPage]);
const products = data?.pages.flatMap(page => page.data) ?? [];
return (
<div>
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
{/* Sentinel element */}
<div ref={ref} className="loading-sentinel">
{isFetchingNextPage && <Spinner />}
</div>
{!hasNextPage && <div>No more products</div>}
</div>
);
}Manual Scroll Detection
function InfiniteProductList() {
const [page, setPage] = useState(1);
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
useEffect(() => {
const handleScroll = () => {
if (loading || !hasMore) return;
const { scrollTop, scrollHeight, clientHeight } = document.documentElement;
// Trigger when 80% scrolled
if (scrollTop + clientHeight >= scrollHeight * 0.8) {
loadMore();
}
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, [loading, hasMore]);
async function loadMore() {
setLoading(true);
const data = await fetchProducts({ page, pageSize: 20 });
setProducts(prev => [...prev, ...data.data]);
setPage(prev => prev + 1);
setHasMore(data.page < data.totalPages);
setLoading(false);
}
return (
<div>
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
{loading && <Spinner />}
</div>
);
}Pros:
- ✅ Seamless UX
- ✅ No click required
- ✅ Great for mobile
- ✅ Addictive scrolling
Cons:
- ⚠️ Hard to reach footer
- ⚠️ Memory issues with thousands of items
- ⚠️ Back button challenges
- ⚠️ SEO challenges
🪟 Pattern 4: Virtual Scrolling
Render only visible items.
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualProductList() {
const parentRef = useRef<HTMLDivElement>(null);
const [products, setProducts] = useState<Product[]>([]);
const virtualizer = useVirtualizer({
count: products.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 100, // Estimated item height
overscan: 5, // Render 5 extra items
});
// Fetch more when nearing end
useEffect(() => {
const [lastItem] = [...virtualizer.getVirtualItems()].reverse();
if (!lastItem) return;
if (
lastItem.index >= products.length - 1 &&
hasNextPage &&
!isFetchingNextPage
) {
fetchNextPage();
}
}, [
virtualizer.getVirtualItems(),
fetchNextPage,
hasNextPage,
isFetchingNextPage,
]);
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const product = products[virtualItem.index];
return (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
<ProductCard product={product} />
</div>
);
})}
</div>
</div>
);
}Pros:
- ✅ Handle millions of items
- ✅ Constant memory usage
- ✅ Smooth scrolling
- ✅ Great performance
Cons:
- ⚠️ Complex implementation
- ⚠️ Fixed/estimated heights required
- ⚠️ Accessibility challenges
🔢 Pattern 5: Load More Button
Manual pagination control.
function ProductList() {
const [products, setProducts] = useState<Product[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const loadMore = async () => {
setLoading(true);
const data = await fetchProducts({
cursor: cursor || undefined,
limit: 20
});
setProducts(prev => [...prev, ...data.data]);
setCursor(data.nextCursor);
setHasMore(data.hasMore);
setLoading(false);
};
useEffect(() => {
loadMore(); // Initial load
}, []);
return (
<div>
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
{hasMore && (
<button onClick={loadMore} disabled={loading}>
{loading ? 'Loading...' : 'Load More'}
</button>
)}
</div>
);
}Pros:
- ✅ User control
- ✅ Simple implementation
- ✅ Good for accessibility
- ✅ SEO-friendly with SSR
Cons:
- ⚠️ Requires user action
- ⚠️ Less engaging than infinite scroll
🎯 Pattern 6: Search Result Pagination
Handle filtered/searched data.
function SearchResults() {
const [query, setQuery] = useState('');
const [page, setPage] = useState(1);
const [debouncedQuery] = useDebounce(query, 300);
const { data, loading } = useQuery({
queryKey: ['search', debouncedQuery, page],
queryFn: () => searchProducts({ query: debouncedQuery, page, pageSize: 20 }),
enabled: debouncedQuery.length > 0,
});
// Reset page when query changes
useEffect(() => {
setPage(1);
}, [debouncedQuery]);
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search products..."
/>
{loading && <Spinner />}
{data && (
<>
<div className="results">
{data.data.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
{data.totalPages > 1 && (
<Pagination
current={page}
total={data.totalPages}
onChange={setPage}
/>
)}
</>
)}
</div>
);
}📊 Comparison Table
| Pattern | Best For | Complexity | Performance | SEO |
|---|---|---|---|---|
| Offset | Small datasets, SEO | Low | Medium | ⭐⭐⭐⭐⭐ |
| Cursor | Large datasets, real-time | Medium | ⭐⭐⭐⭐⭐ | ⭐⭐ |
| Infinite Scroll | Social feeds, mobile | Medium | ⭐⭐⭐⭐ | ⭐ |
| Virtual Scroll | Huge lists (10K+ items) | High | ⭐⭐⭐⭐⭐ | ⭐ |
| Load More | General use | Low | ⭐⭐⭐⭐ | ⭐⭐⭐ |
🏢 Real-World Examples
// Cursor-based pagination
// Infinite scroll
// Virtual scrolling for long threadsAmazon
// Offset pagination for products
// SEO-friendly page numbers
// Load more for reviews// Infinite scroll
// Cursor-based
// Aggressive prefetching// Infinite scroll for feed
// Offset pagination for search
// Load more for connections📚 Key Takeaways
- Offset for SEO and small datasets
- Cursor for large datasets and real-time
- Infinite scroll for engagement (feeds)
- Virtual scroll for huge lists
- Load more for control and accessibility
- Always show loading states
- Prefetch next page for instant UX
Choose based on your data size, SEO needs, and user behavior.