Бесконечная прокрутка
Пагинация с подгрузкой через `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-анимации на строках, которые уходят за пределы экрана. Если ваш дизайн полагается на анимации при наведении или фокусе, ограничьте их видимым окном.
Связанные страницы
- Страница списка товаров — пагинированная альтернатива
- Фасетный поиск
- Фильтрация, сортировка, пагинация
Страница списка товаров
Серверно-рендеренная PLP электронной коммерции с фильтрами, сортировкой, пагинацией и SEO-дружественными URL. Объединяет паттерны фасетного поиска и пагинации в завершённую страницу.
Аналитика кликов
Отслеживайте, по каким результатам пользователи действительно кликают и какие запросы приводят к конверсиям — данные поступают в тюнер релевантности AACsearch и ваши собственные дашборды воронки.