Реалізація нескінченного прокручування (Infinite Scroll) на сайті

Наша компанія займається розробкою, підтримкою та обслуговуванням сайтів будь-якої складності. Від простих односторінкових сайтів до масштабних кластерних систем, побудованих на мікро сервісах. Досвід розробників підтверджено сертифікатами від вендорів.

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

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

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

Пропоновані послуги
Показано 1 з 1 послугУсі 2065 послуг
Реалізація нескінченного прокручування (Infinite Scroll) на сайті
Середня
від 1 робочого дня до 3 робочих днів
Часті питання

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

Етапи розробки

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

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

Реалізація бесконечної прокрутки на сайті

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

Механіка роботи

Два основні способи відстежувати момент підгрузки:

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 дні.