Бесконечная прокрутка реализуется в TanStack Query через
специализированный хук useInfiniteQuery, который расширяет
стандартную модель запросов и добавляет поддержку постраничной загрузки
данных с накоплением результатов в единый виртуальный список. Основная
идея заключается в том, что каждая новая порция данных рассматривается
как отдельная «страница», но в состоянии клиента они объединяются в
последовательную структуру.
В отличие от классического useQuery, где результат
запроса представляет собой единый объект, useInfiniteQuery
оперирует набором страниц:
data.pages;data.pageParams.Структура данных обычно выглядит следующим образом:
{
pages: [
{ items: [...] },
{ items: [...] }
],
pageParams: [undefined, 2]
}
Такой формат позволяет сохранять историю загруженных данных без их перезаписи.
Ключевыми параметрами являются 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, бесконечная загрузка
останавливается.
Типовые стратегии:
Сервер возвращает курсор:
getNextPageParam: (lastPage) => lastPage.nextCursor
Такой подход обеспечивает стабильность данных при изменениях на сервере.
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. При возврате к списку:
Кэширование особенно эффективно при повторных посещениях списков с высокой стоимостью запросов.
При вызове invalidateQueries происходит пересборка всего
набора страниц:
queryClient.invalidateQueries({ queryKey: ['products'] })
Важно учитывать, что при бесконечной пагинации инвалидируется не одна страница, а вся цепочка данных.
useInfiniteQuery предоставляет несколько состояний:
isLoading — первичная загрузка;isFetching — любой фоновый запрос;isFetchingNextPage — загрузка следующей страницы;isError — ошибка запроса.Разделение состояний позволяет точно контролировать UX бесконечного списка.
queryKey должен оставаться стабильным, иначе кэш страниц
теряется. Даже незначительное изменение массива ключа приводит к полной
перезагрузке списка.
Пример нестабильного ключа:
['products', { filter: { category: selectedCategory } }]
Если объект пересоздаётся при каждом рендере, происходит лишняя инвалидация. Для предотвращения используется мемоизация или сериализация параметров.
TanStack Query предотвращает повторные запросы одной и той же страницы, если:
pageParam идентичны.Это критично для интерфейсов с быстрым скроллом.
При больших объёмах данных бесконечная прокрутка часто комбинируется
с виртуализацией (@tanstack/react-virtual), где:
pages;Это снижает нагрузку на браузер при тысячах элементов.
Каждый queryFn получает signal, позволяющий
отменять запрос:
const fetchProducts = async ({ pageParam = 1, signal }) => {
const res = await fetch(`/api/products?page=${pageParam}`, { signal })
return res.json()
}
Это важно при быстром скролле, когда новые страницы заменяют устаревшие запросы.
Ошибка может возникнуть:
TanStack Query хранит ошибки отдельно, но при бесконечной загрузке важно различать:
Второй случай не ломает уже загруженные данные.
При изменении данных на сервере возможны дубликаты или пропуски. Для минимизации проблем применяются:
Offset-based подход чаще приводит к рассинхронизации при вставках или удалениях.
Повторная загрузка страниц может происходить при:
Параметры refetchOnWindowFocus,
refetchInterval, networkMode позволяют
управлять этим поведением и снижать лишнюю нагрузку.
При очень длинных списках может возникать необходимость ограничивать историю:
TanStack Query не ограничивает размер pages, поэтому
ответственность за оптимизацию лежит на архитектуре запроса.