Нескінченний скрол: Intersection Observer, React Query та SEO

Ми часто стикаємося з запитами на впровадження нескінченного скролу (infinite scroll). На перший погляд задача проста — підвантажувати контент у міру скролу. Однак на продакшені виникають проблеми: втрата позиції при навігації назад, SEO-невидимість для пошукових систем, подвійні запити при швидкому

Розробка та обслуговування будь-яких видів сайтів:

Інформаційні сайти або веб-програми
Сайти візитки, landing page, корпоративні сайти, онлайн каталоги, квіз, промо-сайти, блоги, ресурси новин, інформаційні портали, форуми, агрегатори
Сайти або веб-програми електронної комерції
Інтернет-магазини, B2B-портали, маркетплейси, онлайн-обмінники, кешбек-сайти, біржі, дропшиппінг-платформи, парсери товарів
Веб-програми для управління бізнес-процесами
CRM-системи, ERP-системи, корпоративні портали, системи управління виробництвом, парсери інформації
Сайти або веб-програми електронних послуг
Дошки оголошень, онлайн-школи, онлайн-кінотеатри, конструктори сайтів, портали надання електронних послуг, відеохостинги, тематичні портали

Це лише деякі з технічних типів сайтів, з якими ми працюємо, і кожен із них може мати свої специфічні особливості та функціональність, а також бути адаптованим під конкретні потреби та цілі клієнта.

Послуги, які ми пропонуємо
Показано 1 з 1Усі 2062 послуг
Нескінченний скрол: Intersection Observer, React Query та SEO
Середній
~2-3 дні

Наші компетенції:

Часті запитання

Останні роботи

  • image_website-b2b-advance_0.webp
    Розробка сайту компанії B2B ADVANCE
    1421
  • image_web-applications_feedme_466_0.webp
    Розробка веб-додатків для компанії FEEDME
    1287
  • image_websites_belfingroup_462_0.webp
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    984
  • image_ecommerce_furnoro_435_0.webp
    Розробка інтернет магазину для компанії FURNORO
    1248
  • image_crm_enviok_479_0.webp
    Розробка веб-додатків для компанії Enviok
    986
  • image_bitrix-bitrix-24-1c_fixper_448_0.webp
    Розробка веб-сайту для компанії ФІКСПЕР
    999

Ми часто стикаємося з запитами на впровадження нескінченного скролу (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 зазвичай не прокручує сторінку до нескінченності. Рішення:

  1. Перша сторінка рендериться на сервері (SSR/SSG).
  2. Наступні сторінки доступні за URL з параметром пагінації.
  3. Sitemap містить URL всіх записів напряму.
  4. Додайте релевантні мета-теги та 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 з урахуванням усіх нюансів.