Infinite scroll

Бесконечная прокрутка реализуется в TanStack Query через специализированный хук useInfiniteQuery, который расширяет стандартную модель запросов и добавляет поддержку постраничной загрузки данных с накоплением результатов в единый виртуальный список. Основная идея заключается в том, что каждая новая порция данных рассматривается как отдельная «страница», но в состоянии клиента они объединяются в последовательную структуру.

Базовая модель данных

В отличие от классического useQuery, где результат запроса представляет собой единый объект, useInfiniteQuery оперирует набором страниц:

  • каждая страница — результат отдельного запроса;
  • все страницы хранятся в data.pages;
  • индексы страниц доступны через data.pageParams.

Структура данных обычно выглядит следующим образом:

{
  pages: [
    { items: [...] },
    { items: [...] }
  ],
  pageParams: [undefined, 2]
}

Такой формат позволяет сохранять историю загруженных данных без их перезаписи.

Основная конфигурация useInfiniteQuery

Ключевыми параметрами являются queryFn, getNextPageParam и initialPageParam.

import { useInfiniteQuery } from '@tanstack/react-query'

const fetchProducts = async ({ pageParam = 1 }) => {
  const res = await fetch(`/api/products?page=${pageParam}`)
  return res.json()
}

const query = useInfiniteQuery({
  queryKey: ['products'],
  queryFn: fetchProducts,
  initialPageParam: 1,
  getNextPageParam: (lastPage, allPages) => {
    return lastPage.nextPage ?? undefined
  }
})

Механизм определения следующей страницы

Функция getNextPageParam определяет, существует ли следующая порция данных. Она получает:

  • lastPage — последний загруженный блок данных;
  • allPages — массив всех загруженных страниц.

Возвращаемое значение становится новым pageParam. Если возвращается undefined, бесконечная загрузка останавливается.

Типовые стратегии:

Cursor-based пагинация

Сервер возвращает курсор:

getNextPageParam: (lastPage) => lastPage.nextCursor

Такой подход обеспечивает стабильность данных при изменениях на сервере.

Offset-based пагинация

getNextPageParam: (lastPage, allPages) => {
  return lastPage.hasMore ? allPages.length + 1 : undefined
}

Подходит для простых API, но менее устойчив при изменениях данных.

Загрузка следующей страницы

Для управления загрузкой используется функция fetchNextPage:

const {
  data,
  fetchNextPage,
  hasNextPage,
  isFetchingNextPage
} = useInfiniteQuery(...)

Логика загрузки обычно завязана на пользовательское событие:

<button
  onCl ick={() => fetchNextPage()}
  disabled={!hasNextPage || isFetchingNextPage}
>
  Загрузить ещё
</button>

Объединение страниц в единый список

Так как данные хранятся постранично, для отображения используется flatten-операция:

const items = data.pages.flatMap(page => page.items)

Это позволяет абстрагироваться от структуры страниц и работать с единым массивом.

Автоматическая загрузка при скролле

Часто бесконечная прокрутка реализуется через IntersectionObserver, который отслеживает появление sentinel-элемента.

useEffect(() => {
  if (!hasNextPage || isFetchingNextPage) return

  const observer = new IntersectionObserver(entries => {
    if (entries[0].isIntersecting) {
      fetchNextPage()
    }
  })

  if (loaderRef.current) {
    observer.observe(loaderRef.current)
  }

  return () => observer.disconnect()
}, [hasNextPage, isFetchingNextPage])

Элемент loaderRef размещается в конце списка и служит триггером загрузки.

Кэширование и повторное использование страниц

TanStack Query кэширует каждую страницу в рамках одного queryKey. При возврате к списку:

  • уже загруженные страницы не запрашиваются повторно;
  • состояние прокрутки может быть восстановлено на уровне UI;
  • новые страницы догружаются только при необходимости.

Кэширование особенно эффективно при повторных посещениях списков с высокой стоимостью запросов.

Инвалидация и обновление данных

При вызове invalidateQueries происходит пересборка всего набора страниц:

queryClient.invalidateQueries({ queryKey: ['products'] })

Важно учитывать, что при бесконечной пагинации инвалидируется не одна страница, а вся цепочка данных.

Обработка состояния загрузки

useInfiniteQuery предоставляет несколько состояний:

  • isLoading — первичная загрузка;
  • isFetching — любой фоновый запрос;
  • isFetchingNextPage — загрузка следующей страницы;
  • isError — ошибка запроса.

Разделение состояний позволяет точно контролировать UX бесконечного списка.

Стабильность ключей и идентичность данных

queryKey должен оставаться стабильным, иначе кэш страниц теряется. Даже незначительное изменение массива ключа приводит к полной перезагрузке списка.

Пример нестабильного ключа:

['products', { filter: { category: selectedCategory } }]

Если объект пересоздаётся при каждом рендере, происходит лишняя инвалидация. Для предотвращения используется мемоизация или сериализация параметров.

Дедупликация запросов

TanStack Query предотвращает повторные запросы одной и той же страницы, если:

  • предыдущий запрос ещё выполняется;
  • результат уже закэширован;
  • параметры pageParam идентичны.

Это критично для интерфейсов с быстрым скроллом.

Взаимодействие с виртуализацией списка

При больших объёмах данных бесконечная прокрутка часто комбинируется с виртуализацией (@tanstack/react-virtual), где:

  • DOM содержит только видимую часть элементов;
  • данные продолжают накапливаться в pages;
  • рендеринг не зависит от общего размера списка.

Это снижает нагрузку на браузер при тысячах элементов.

Прерывание и отмена запросов

Каждый queryFn получает signal, позволяющий отменять запрос:

const fetchProducts = async ({ pageParam = 1, signal }) => {
  const res = await fetch(`/api/products?page=${pageParam}`, { signal })
  return res.json()
}

Это важно при быстром скролле, когда новые страницы заменяют устаревшие запросы.

Ошибки при постраничной загрузке

Ошибка может возникнуть:

  • на первой странице;
  • на любой последующей странице.

TanStack Query хранит ошибки отдельно, но при бесконечной загрузке важно различать:

  • фатальную ошибку начальной загрузки;
  • ошибку дополнительной страницы.

Второй случай не ломает уже загруженные данные.

Синхронизация с серверными изменениями

При изменении данных на сервере возможны дубликаты или пропуски. Для минимизации проблем применяются:

  • cursor-based пагинация;
  • стабильные сортировки;
  • фиксированные критерии выборки.

Offset-based подход чаще приводит к рассинхронизации при вставках или удалениях.

Контроль повторной загрузки

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

  • возврате фокуса на вкладку;
  • восстановлении сети;
  • рефетче по таймеру.

Параметры refetchOnWindowFocus, refetchInterval, networkMode позволяют управлять этим поведением и снижать лишнюю нагрузку.

Оптимизация количества страниц

При очень длинных списках может возникать необходимость ограничивать историю:

  • хранение только последних N страниц;
  • агрегация данных на сервере;
  • сброс кэша при смене фильтров.

TanStack Query не ограничивает размер pages, поэтому ответственность за оптимизацию лежит на архитектуре запроса.