Ми часто стикаємося з запитами на впровадження нескінченного скролу (infinite scroll). На перший погляд задача проста — підвантажувати контент у міру скролу. Однак на продакшені виникають проблеми: втрата позиції при навігації назад, SEO-невидимість для пошукових систем, подвійні запити при швидкому скролі та порушення доступності для користувачів клавіатури. Нещодавно один клієнт зі стрічкою новин поскаржився, що 30% сесій втрачають контекст через скидання скролу. Ми впровадили рішення на основі Intersection Observer та cursor-based пагінації — після цього повторні запити знизилися в 5 разів, а відмови впали на 12%. У цій статті розберемо промислове рішення на React з Intersection Observer, React Query та cursor-based пагінацією, яке вирішує всі ці проблеми. Наш досвід впровадження — понад 50 проєктів з infinite scroll, від стрічок новин до каталогів товарів. Гарантуємо коректну роботу на будь-яких пристроях.
Intersection Observer: принцип роботи та переваги перед scroll-подіями
Intersection Observer — це API браузера, яке асинхронно відстежує перетин елемента з областю перегляду. Ми створюємо невидимий елемент-маяк у кінці списку та при його появі запускаємо завантаження наступної порції. Це ефективніше за scroll-іденти: не впливає на продуктивність, дає точне спрацювання та споживає менше ресурсів CPU. Нижче — базова реалізація на TypeScript:
class InfiniteScroller {
private page = 1
private loading = false
private hasMore = true
private sentinel: HTMLElement
constructor(
private container: HTMLElement,
private fetchFn: (page: number) => Promise<{ items: any[]; hasMore: boolean }>,
private renderFn: (items: any[]) => void,
) {
this.sentinel = this.createSentinel()
this.observe()
}
private createSentinel(): HTMLElement {
const el = document.createElement('div')
el.className = 'infinite-sentinel'
el.setAttribute('aria-hidden', 'true')
this.container.appendChild(el)
return el
}
private observe() {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && !this.loading && this.hasMore) {
this.loadMore()
}
},
{ rootMargin: '300px' }
)
observer.observe(this.sentinel)
}
private async loadMore() {
this.loading = true
this.showLoader()
try {
const { items, hasMore } = await this.fetchFn(this.page)
this.renderFn(items)
this.hasMore = hasMore
this.page++
if (!hasMore) {
this.sentinel.remove()
this.showEndMessage()
}
} catch (err) {
this.showError(() => this.loadMore())
} finally {
this.loading = false
this.hideLoader()
}
}
}
У React цей патерн зручно реалізувати через useInfiniteQuery з React Query та хук useInView:
import { useInfiniteQuery } from '@tanstack/react-query'
import { useInView } from 'react-intersection-observer'
import { useEffect } from 'react'
interface Post { id: number; title: string; body: string }
interface PostsPage { items: Post[]; nextCursor: number | null; total: number }
async function fetchPosts(cursor?: number): Promise<PostsPage> {
const params = new URLSearchParams({ limit: '20' })
if (cursor) params.set('cursor', String(cursor))
const res = await fetch(`/api/posts?${params}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
}
export function InfinitePostList() {
const { ref: sentinelRef, inView } = useInView({ rootMargin: '400px' })
const {
data,
error,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
status,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam }) => fetchPosts(pageParam as number | undefined),
initialPageParam: undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
staleTime: 5 * 60 * 1000,
})
useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage) {
fetchNextPage()
}
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage])
if (status === 'pending') return <PostListSkeleton count={5} />
if (status === 'error') return <ErrorMessage error={error} />
const allPosts = data.pages.flatMap(page => page.items)
const total = data.pages[0]?.total ?? 0
return (
<div>
<p className="results-count" aria-live="polite">
Показано {allPosts.length} з {total}
</p>
<div className="posts-grid" role="feed" aria-busy={isFetchingNextPage}>
{allPosts.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
<div ref={sentinelRef} aria-hidden="true" />
{isFetchingNextPage && <PostListSkeleton count={3} />}
{!hasNextPage && allPosts.length > 0 && (
<p className="end-message">Всі записи завантажено</p>
)}
</div>
)
}
Cursor-based пагінація: надійність у порівнянні з offset
Offset-пагінація (LIMIT 20 OFFSET 100) дає збої при паралельних вставках — нові записи зсувають сторінки, користувач пропускає або бачить дублі. Cursor-based пагінація використовує унікальний ключ (наприклад, ID) для вибірки наступних записів. Вона стабільна, виключає зсуви та не ламається при додаванні контенту. Порівняємо їх:
| Критерій | Offset-пагінація | Cursor-based пагінація |
|---|---|---|
| Стабільність при вставках | Ламається (дублі/пропуски) | Стійка |
| Продуктивність на великих даних | Гірша (OFFSET сканує рядки) | Краща (індексний пошук) |
| Підтримка довільної сторінки | Так | Ні (тільки вперед/назад) |
| Реалізація на сервері | Проста | Трохи складніше |
Cursor-based пагінація в 5 разів зменшує кількість повторних запитів порівняно з offset при частих вставках. Приклад реалізації на Laravel:
public function index(Request $request): JsonResponse
{
$limit = min($request->integer('limit', 20), 100);
$cursor = $request->integer('cursor', 0);
$query = Post::query()
->orderBy('id', 'desc')
->with(['author', 'tags']);
if ($cursor > 0) {
$query->where('id', '<', $cursor);
}
$items = $query->limit($limit + 1)->get();
$hasMore = $items->count() > $limit;
if ($hasMore) $items->pop();
return response()->json([
'items' => PostResource::collection($items),
'nextCursor' => $hasMore ? $items->last()->id : null,
'total' => Post::count(),
]);
}
Як відновити позицію скролу після переходу?
Головний головний біль infinite scroll — втрата позиції. Користувач проскролив 5 сторінок, клікнув на статтю, повернувся — і знову бачить початок. Рішення: перед переходом зберігаємо window.scrollY у sessionStorage, а після повернення відновлюємо позицію після рендеру. Більш просунутий спосіб — зберігати весь стан завантажених сторінок у кеші React Query через persistQueryClient. В одному з проєктів це скоротило час відновлення з 2 секунд до 200 мс.
Доступність: кнопка «Завантажити ще» як fallback
Infinite scroll поганий для користувачів клавіатури — вони не можуть дістатися до footer. Додаємо кнопку «Завантажити ще» в кінці списку, яка дублює автозавантаження. Це покращує accessibility та відповідає WCAG.
Скелетон-завантаження для плавного UX
Щоб уникнути ривків, показуємо скелетони під час завантаження. Приклад компонента та стилів:
function PostCardSkeleton() {
return (
<div className="post-card post-card--skeleton" aria-hidden="true">
<div className="skeleton skeleton--image" />
<div className="skeleton skeleton--title" />
<div className="skeleton skeleton--line" />
<div className="skeleton skeleton--line skeleton--line-short" />
</div>
)
}
function PostListSkeleton({ count }: { count: number }) {
return (
<>
{Array.from({ length: count }).map((_, i) => (
<PostCardSkeleton key={i} />
))}
</>
)
}
/* CSS */
.skeleton {
background: linear-gradient(90deg, #f1f5f9 25%, #e2e8f0 50%, #f1f5f9 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.skeleton--image { height: 200px; margin-bottom: 12px; }
.skeleton--title { height: 24px; width: 70%; margin-bottom: 8px; }
.skeleton--line { height: 16px; margin-bottom: 6px; }
.skeleton--line-short { width: 50%; }
Як зробити infinite scroll SEO-дружнім?
Google зазвичай не прокручує сторінку до нескінченності. Рішення:
- Перша сторінка рендериться на сервері (SSR/SSG).
- Наступні сторінки доступні за URL з параметром пагінації.
- Sitemap містить URL всіх записів напряму.
- Додайте релевантні мета-теги та canonical URL.
Обробка помилок та повторні спроби
При мережевих збоях infinite scroll може зупинитися. Налаштуйте retry у React Query: retry: 3, retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 10000). Також додайте сповіщення про помилку з кнопкою повторної спроби. У нас був досвід, коли це дозволило відновити завантаження в 95% випадків тимчасових збоїв.
Що входить у роботу під ключ?
- Аналіз поточної пагінації та даних
- Проєктування архітектури (Frontend + Backend)
- Реалізація Intersection Observer та React Query
- Впровадження cursor-based API на сервері
- Обробка помилок та retry
- Відновлення позиції скролу
- Скелетон-завантаження та прелоадери
- Повна accessibility (кнопка fallback, ARIA-атрибути)
- SEO-оптимізація (SSR першої сторінки, окремі URL)
- Тестування та деплой
Терміни впровадження
| Етап | Термін |
|---|---|
| Базова реалізація (Intersection Observer + прелоадер) | від 1 дня |
| React Query + cursor-based API + відновлення позиції | 2–3 дні |
| Повний цикл з accessibility, SSR та SEO | 4–5 днів |
Вартість розраховується індивідуально після аналізу вашого проєкту. Оцінимо обсяг і запропонуємо оптимальне рішення. Зв'яжіться з нами для консультації — ми гарантуємо якісну реалізацію infinite scroll з урахуванням усіх нюансів.







