Реалізація бесконечної прокрутки на сайті
Бесконечна прокрутка заміняє пагінацію безперервною підгрузкою даних при досягненні нижної границі контейнера. Підхід доцільний для лент контенту, каталогів товарів, медіагалерей — там, де користувач споживає контент лінійно й не потребує навігації по конкретних сторінках.
Механіка роботи
Два основні способи відстежувати момент підгрузки:
IntersectionObserver — сучасний й виробничий. Браузер сам сповіщає, коли sentinel-елемент (порожній div в кінці списку) входить у зону видимості. Не навантажує потік подій scroll.
scroll event — застарілий спосіб. Потребує throttle/debounce, обчислення scrollTop + clientHeight >= scrollHeight. Уникайте на мобільних пристроях.
Реалізація на React
// hooks/useInfiniteScroll.ts
import { useEffect, useRef, useCallback } from 'react'
interface UseInfiniteScrollOptions {
onLoadMore: () => void
hasMore: boolean
isLoading: boolean
threshold?: number // px до краю, при якому триґерится завантаження
}
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('Помилка завантаження. Спробуйте ще раз.')
} finally {
setIsLoading(false)
}
}, [cursor, hasMore, isLoading])
// Завантажуємо першу сторінку при монтуванні
// useEffect(() => { loadMore() }, []) — опущено для стислості
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 — невидимий триґер */}
<div ref={sentinelRef} aria-hidden="true" />
{!hasMore && <p className="text-center text-muted">Усі товари завантажені</p>}
</div>
)
}
Серверна частина: cursor-based пагінація
Offset-пагінація (LIMIT 20 OFFSET 100) ломається при додаванні нових записів — користувач отримує дублі або пропускає елементи. Cursor-based розв'язує проблему:
-- Перший запит
SELECT id, title, image, created_at
FROM products
WHERE is_active = true
ORDER BY created_at DESC, id DESC
LIMIT 21; -- +1 щоб зрозуміти, чи є наступна сторінка
-- Наступний запит (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 контролер
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
Для продакшену краще використовувати useInfiniteQuery — він береж на себе кеширування, дедупліцирування запитів, фонове оновлення:
import { useInfiniteQuery } from '@tanstack/react-query'
function useProducts() {
return useInfiniteQuery({
queryKey: ['products'],
queryFn: ({ pageParam }) => fetchProducts(pageParam ?? null),
getNextPageParam: (lastPage) => lastPage.nextCursor,
initialPageParam: null,
})
}
// У компоненті:
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useProducts()
const items = data?.pages.flatMap(p => p.data) ?? []
Віртуалізація для великих списків
При кількасот елементів у DOM продуктивність падає. Рішення — віртуальний скролл: рендеритися тільки видимий діапазон.
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, // висота карточки в 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>
)
}
Доступність та UX
Чистий infinite scroll створює проблеми: користувач не може дістатися до футера, втрачає позицію після переходу на іншу сторінку. Практичні рішення:
- Кнопка «Завантажити ще» замість автоматичного триґеру — користувач контролює завантаження
- Збереження позиції скролу в URL або sessionStorage при переходах
- Aria-live регіон для сповіщення скринрідерів про нові елементи
- Кнопка «Наверх» при завантаженні більше 3 сторінок
// Збереження позиції
useEffect(() => {
const saved = sessionStorage.getItem('scroll-products')
if (saved) window.scrollTo(0, parseInt(saved))
return () => {
sessionStorage.setItem('scroll-products', String(window.scrollY))
}
}, [])
Строки виконання
Базова реалізація IntersectionObserver + API endpoint — 1 день. З cursor-пагінацією на бекенді, React Query, віртуалізацією та відновленням позиції — 3–4 дні.







