AACsearch
SDKРецепты

Бесконечная прокрутка

Пагинация с подгрузкой через `useInfiniteQuery` TanStack Query и sentinel на IntersectionObserver — один round-trip на скролл, без бесконечных спиннеров.

Бесконечная прокрутка лучше всего подходит, когда пользователь просматривает, а не ищет конкретный элемент. Этот рецепт использует useInfiniteQuery для управления состоянием и IntersectionObserver для триггера скролла — без обработчиков события scroll, без ручного debounce.

Что вы создаёте

Компонент <ProductFeed>, который:

  • Загружает 24 товара при монтировании.
  • Загружает следующие 24, когда пользователь докручивает до ~400 px от низа.
  • Прекращает загрузку, когда hits.length >= found.
  • Корректно восстанавливается после сетевых ошибок.

Компонент

"use client";

import { useEffect, useRef } from "react";
import { useInfiniteQuery } from "@tanstack/react-query";
import { SearchClient } from "@aacsearch/client";

const client = new SearchClient({
  baseUrl: process.env.NEXT_PUBLIC_AACSEARCH_BASE_URL!,
  apiKey: process.env.NEXT_PUBLIC_AACSEARCH_SEARCH_KEY!,
  indexSlug: "products",
});

const PER_PAGE = 24;

export function ProductFeed({ filterBy }: { filterBy?: string }) {
  const sentinelRef = useRef<HTMLDivElement>(null);

  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    status,
    error,
  } = useInfiniteQuery({
    queryKey: ["product-feed", filterBy ?? ""],
    initialPageParam: 1,
    queryFn: ({ pageParam, signal }) =>
      client.search(
        {
          q: "*",
          filterBy,
          page: pageParam,
          perPage: PER_PAGE,
          sortBy: "popularity:desc",
        },
        { signal },
      ),
    getNextPageParam: (lastPage, allPages) => {
      const loadedSoFar = allPages.reduce((sum, p) => sum + p.hits.length, 0);
      return loadedSoFar < lastPage.found ? allPages.length + 1 : undefined;
    },
  });

  useEffect(() => {
    const sentinel = sentinelRef.current;
    if (!sentinel) return;

    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
          fetchNextPage();
        }
      },
      { rootMargin: "400px" },
    );

    observer.observe(sentinel);
    return () => observer.disconnect();
  }, [hasNextPage, isFetchingNextPage, fetchNextPage]);

  if (status === "pending") return <SkeletonGrid />;
  if (status === "error") return <ErrorBanner error={error} />;

  const allHits = data.pages.flatMap((p) => p.hits);

  return (
    <>
      <div className="product-grid">
        {allHits.map((hit) => (
          <ProductCard key={hit.document.id} product={hit.document} />
        ))}
      </div>
      <div ref={sentinelRef} className="sentinel" aria-hidden="true">
        {isFetchingNextPage && <LoadingSpinner />}
        {!hasNextPage && allHits.length > 0 && <p>Вы достигли конца.</p>}
      </div>
    </>
  );
}

Почему IntersectionObserver, а не onScroll

IntersectionObserver работает вне основного потока и срабатывает только когда sentinel входит в область просмотра. Обработчик scroll срабатывает до 60 раз в секунду и вынуждает вас делать debounce или throttle. Без вариантов.

rootMargin: "400px" запускает следующую подгрузку примерно за 400 px до того, как пользователь фактически достигнет низа — новая страница часто приходит до того, как пользователь замечает, что он близок к концу.

Восстановление позиции скролла при обратной навигации

По умолчанию возвращение на страницу сбрасывает скролл. Для восстановления:

"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";

export function useScrollRestore(key: string) {
  useEffect(() => {
    const saved = sessionStorage.getItem(`scroll:${key}`);
    if (saved) window.scrollTo(0, Number(saved));

    const onScroll = () => {
      sessionStorage.setItem(`scroll:${key}`, String(window.scrollY));
    };
    window.addEventListener("scroll", onScroll, { passive: true });
    return () => window.removeEventListener("scroll", onScroll);
  }, [key]);
}

В сочетании с кешем TanStack Query (высокий staleTime) пользователь возвращается на ту же позицию скролла и к тем же данным после обратной навигации со страницы товара.

useInfiniteQuery({
  queryKey: ["product-feed", filterBy],
  staleTime: 5 * 60 * 1000, // 5 минут — лента «достаточно свежая»
  gcTime: 10 * 60 * 1000,
  // ...
});

Компромиссы по сравнению с пагинированной PLP

АспектБесконечная прокруткаПагинированная PLP
UX просмотраПлавнееВыше когнитивная нагрузка
Глубокие ссылкиСложно («страница 47» не имеет смысла)Тривиально (?page=47)
SEOХуже — Googlebot не скроллитНативно — каждая страница индексируется
ФутерЧасто недоступенДоступен
ПамятьНеограниченно растётОграничена

Для PLP используйте пагинацию. Для поверхностей «просмотр» / «лента» — бесконечную прокрутку. Комбинируйте: пагинация для SEO + «бесконечное» улучшение только на клиенте после отрисовки первой страницы.

Память: виртуализация последних N страниц

После ~5 страниц (120 карточек) узлы DOM начинают тормозить. Используйте react-virtual или tanstack-virtual для переиспользования строк вне экрана:

import { useVirtualizer } from "@tanstack/react-virtual";

const virtualizer = useVirtualizer({
  count: allHits.length,
  getScrollElement: () => containerRef.current,
  estimateSize: () => 320,
  overscan: 5,
});

Виртуализация отключает CSS-анимации на строках, которые уходят за пределы экрана. Если ваш дизайн полагается на анимации при наведении или фокусе, ограничьте их видимым окном.

Связанные страницы

On this page