Infinite List
A performant infinite scroll implementation with TanStack Query and intersection observer.
Overview
This template provides:
- Infinite scroll with TanStack Query
- Intersection observer for scroll detection
- Loading states and error handling
- Pull-to-refresh support
- Optional virtualization for large lists
- Skeleton loading placeholders
Dependencies
npm install @tanstack/react-queryFor virtualization (optional):
npm install @tanstack/react-virtualCode
import { useRef, useCallback, useEffect } from 'react';
import { Loader2 } from 'lucide-react';
interface InfiniteListProps<T> {
items: T[];
renderItem: (item: T, index: number) => React.ReactNode;
keyExtractor: (item: T) => string;
onLoadMore: () => void;
hasNextPage: boolean;
isFetchingNextPage: boolean;
isLoading: boolean;
isError: boolean;
error?: Error | null;
emptyMessage?: string;
loadingComponent?: React.ReactNode;
errorComponent?: React.ReactNode;
className?: string;
}
export function InfiniteList<T>({
items,
renderItem,
keyExtractor,
onLoadMore,
hasNextPage,
isFetchingNextPage,
isLoading,
isError,
error,
emptyMessage = 'No items found',
loadingComponent,
errorComponent,
className,
}: InfiniteListProps<T>) {
const observerRef = useRef<IntersectionObserver | null>(null);
const lastItemRef = useCallback(
(node: HTMLDivElement | null) => {
if (isFetchingNextPage) return;
if (observerRef.current) {
observerRef.current.disconnect();
}
observerRef.current = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage) {
onLoadMore();
}
},
{ threshold: 0.1, rootMargin: '100px' }
);
if (node) {
observerRef.current.observe(node);
}
},
[isFetchingNextPage, hasNextPage, onLoadMore]
);
// Cleanup observer on unmount
useEffect(() => {
return () => {
if (observerRef.current) {
observerRef.current.disconnect();
}
};
}, []);
// Initial loading state
if (isLoading) {
return loadingComponent ?? <LoadingSkeletons count={5} />;
}
// Error state
if (isError) {
return (
errorComponent ?? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<p className="text-red-500">Failed to load items</p>
<p className="text-sm text-muted-foreground">{error?.message}</p>
</div>
)
);
}
// Empty state
if (items.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<p className="text-muted-foreground">{emptyMessage}</p>
</div>
);
}
return (
<div className={className}>
{items.map((item, index) => (
<div
key={keyExtractor(item)}
ref={index === items.length - 1 ? lastItemRef : undefined}
>
{renderItem(item, index)}
</div>
))}
{/* Loading indicator for next page */}
{isFetchingNextPage && (
<div className="flex justify-center py-4">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)}
{/* End of list indicator */}
{!hasNextPage && items.length > 0 && (
<p className="py-4 text-center text-sm text-muted-foreground">
No more items to load
</p>
)}
</div>
);
}
function LoadingSkeletons({ count }: { count: number }) {
return (
<div className="space-y-4">
{Array.from({ length: count }).map((_, i) => (
<div
key={i}
className="h-24 animate-pulse rounded-lg bg-muted"
/>
))}
</div>
);
}Virtualization
For very large lists (1000+ items), add virtualization to render only visible items:
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
function VirtualizedList<T>({
items,
renderItem,
estimateSize = 100,
}: {
items: T[];
renderItem: (item: T, index: number) => React.ReactNode;
estimateSize?: number;
}) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => estimateSize,
overscan: 5,
});
return (
<div ref={parentRef} className="h-[600px] overflow-auto">
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
}}
>
{renderItem(items[virtualItem.index], virtualItem.index)}
</div>
))}
</div>
</div>
);
}Pull to Refresh
Add pull-to-refresh functionality:
import { useCallback, useState } from 'react';
function usePullToRefresh(onRefresh: () => Promise<void>) {
const [isRefreshing, setIsRefreshing] = useState(false);
const handleRefresh = useCallback(async () => {
setIsRefreshing(true);
try {
await onRefresh();
} finally {
setIsRefreshing(false);
}
}, [onRefresh]);
return { isRefreshing, handleRefresh };
}
// Usage with react-pull-to-refresh or similar libraryAPI Response Format
The infinite query expects this response format:
interface PaginatedResponse<T> {
items: T[]; // Array of items for current page
nextCursor?: string; // Cursor for next page (undefined if no more)
hasMore: boolean; // Whether more items exist
}Cursor vs Offset Pagination
| Approach | Pros | Cons |
|---|---|---|
| Cursor | Consistent with real-time data, performant | Complex to implement |
| Offset | Simple, jump to any page | Inconsistent with changing data |
Use cursor-based pagination for infinite scroll. Offset pagination can cause duplicate or missing items when data changes.
Related
- Data Table - Paginated table view
- Search with Filters - Filtering with infinite scroll