Implementing Infinite Scroll on a Website
Infinite scroll replaces pagination with continuous data loading when reaching the bottom of a container. The approach is suitable for content feeds, product catalogs, media galleries — where users consume content linearly and don't need navigation to specific pages.
How It Works
Two main ways to track loading moment:
IntersectionObserver — modern and performant. Browser notifies when sentinel element (empty div at list end) enters visible zone. Doesn't overload event stream.
scroll event — outdated method. Requires throttle/debounce, scrollTop + clientHeight >= scrollHeight calculation. Avoid on mobile.
React Implementation
// hooks/useInfiniteScroll.ts
import { useEffect, useRef, useCallback } from 'react'
interface UseInfiniteScrollOptions {
onLoadMore: () => void
hasMore: boolean
isLoading: boolean
threshold?: number // px from edge when loading triggers
}
export function useInfiniteScroll({
onLoadMore,
hasMore,
isLoading,
threshold = 200,
}: UseInfiniteScrollOptions) {
const sentinelRef = useRef<HTMLDivElement>(null)
const handleIntersect = useCallback(
(entries: IntersectionObserverEntry[]) => {
const [entry] = entries
if (entry.isIntersecting && hasMore && !isLoading) {
onLoadMore()
}
},
[onLoadMore, hasMore, isLoading]
)
useEffect(() => {
const sentinel = sentinelRef.current
if (!sentinel) return
const observer = new IntersectionObserver(handleIntersect, {
rootMargin: `${threshold}px`,
})
observer.observe(sentinel)
return () => observer.disconnect()
}, [handleIntersect, threshold])
return sentinelRef
}
// components/ProductList.tsx
import { useState, useCallback } from 'react'
import { useInfiniteScroll } from '@/hooks/useInfiniteScroll'
interface Product {
id: number
title: string
image: string
}
interface Page {
data: Product[]
nextCursor: string | null
}
async function fetchProducts(cursor: string | null): Promise<Page> {
const params = cursor ? `?cursor=${cursor}` : ''
const res = await fetch(`/api/products${params}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
}
export function ProductList() {
const [items, setItems] = useState<Product[]>([])
const [cursor, setCursor] = useState<string | null>(null)
const [hasMore, setHasMore] = useState(true)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const loadMore = useCallback(async () => {
if (isLoading || !hasMore) return
setIsLoading(true)
setError(null)
try {
const page = await fetchProducts(cursor)
setItems(prev => [...prev, ...page.data])
setCursor(page.nextCursor)
setHasMore(page.nextCursor !== null)
} catch (e) {
setError('Loading error. Try again.')
} finally {
setIsLoading(false)
}
}, [cursor, hasMore, isLoading])
// Load first page on mount
// useEffect(() => { loadMore() }, []) — omitted for brevity
const sentinelRef = useInfiniteScroll({ onLoadMore: loadMore, hasMore, isLoading })
return (
<div>
<ul className="grid grid-cols-3 gap-4">
{items.map(p => (
<li key={p.id}>
<img src={p.image} alt={p.title} loading="lazy" />
<span>{p.title}</span>
</li>
))}
</ul>
{error && (
<button onClick={loadMore} className="mt-4 btn-retry">
{error}
</button>
)}
{isLoading && <Spinner />}
{/* Sentinel — invisible trigger */}
<div ref={sentinelRef} aria-hidden="true" />
{!hasMore && <p className="text-center text-muted">All products loaded</p>}
</div>
)
}
Server Side: Cursor-Based Pagination
Offset pagination (LIMIT 20 OFFSET 100) breaks when new records are added — user gets duplicates or skips elements. Cursor-based solves it:
-- First request
SELECT id, title, image, created_at
FROM products
WHERE is_active = true
ORDER BY created_at DESC, id DESC
LIMIT 21; -- +1 to know if there's next page
-- Next request (cursor = base64(created_at + ':' + id))
SELECT id, title, image, created_at
FROM products
WHERE is_active = true
AND (created_at, id) < ('2024-11-15 10:30:00', 4821)
ORDER BY created_at DESC, id DESC
LIMIT 21;
// Laravel controller
public function index(Request $request): JsonResponse
{
$limit = 20;
$cursor = $request->input('cursor');
$query = Product::where('is_active', true)
->orderByDesc('created_at')
->orderByDesc('id');
if ($cursor) {
[$date, $id] = explode(':', base64_decode($cursor));
$query->where(function ($q) use ($date, $id) {
$q->where('created_at', '<', $date)
->orWhere(function ($q2) use ($date, $id) {
$q2->where('created_at', $date)->where('id', '<', $id);
});
});
}
$items = $query->limit($limit + 1)->get();
$hasMore = $items->count() > $limit;
$data = $items->take($limit);
$nextCursor = $hasMore
? base64_encode($data->last()->created_at . ':' . $data->last()->id)
: null;
return response()->json([
'data' => ProductResource::collection($data),
'nextCursor' => $nextCursor,
]);
}
React Query / TanStack Query
For production, use useInfiniteQuery — it handles caching, deduplication, background refresh:
import { useInfiniteQuery } from '@tanstack/react-query'
function useProducts() {
return useInfiniteQuery({
queryKey: ['products'],
queryFn: ({ pageParam }) => fetchProducts(pageParam ?? null),
getNextPageParam: (lastPage) => lastPage.nextCursor,
initialPageParam: null,
})
}
// In component:
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useProducts()
const items = data?.pages.flatMap(p => p.data) ?? []
Virtualization for Large Lists
With hundreds of items in DOM, performance drops. Solution — virtual scroll: render only visible range.
import { useVirtualizer } from '@tanstack/react-virtual'
function VirtualProductList({ items }: { items: Product[] }) {
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 200, // card height in px
overscan: 5,
})
return (
<div ref={parentRef} style={{ height: '100vh', overflow: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map(virtualItem => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: virtualItem.start,
width: '100%',
}}
>
<ProductCard product={items[virtualItem.index]} />
</div>
))}
</div>
</div>
)
}
Accessibility and UX
Pure infinite scroll creates issues: user can't reach footer, loses position after navigation. Practical solutions:
- "Load more" button instead of auto-trigger — user controls loading
- Save scroll position in URL or sessionStorage on navigation
- Aria-live region to notify screen readers of new items
- "To top" button after 3+ page loads
// Save position
useEffect(() => {
const saved = sessionStorage.getItem('scroll-products')
if (saved) window.scrollTo(0, parseInt(saved))
return () => {
sessionStorage.setItem('scroll-products', String(window.scrollY))
}
}, [])
Timeline
Basic IntersectionObserver + API endpoint — 1 day. With cursor pagination on backend, React Query, virtualization, and position recovery — 3–4 days.







