Двунаправленная бесконечная загрузка применяется в сценариях, где данные можно прокручивать одновременно вперёд и назад: чаты, ленты событий, журналы операций, таймлайны. В отличие от классической пагинации или одностороннего infinite scroll, здесь система должна уметь динамически подгружать страницы как при движении вниз, так и при прокрутке вверх, сохраняя целостность кеша и корректную навигацию по курсорам.
В TanStack Query эта модель реализуется через
useInfiniteQuery, расширенную логикой работы с
getNextPageParam и getPreviousPageParam, а
также ручным управлением страницами в кеше.
useInfiniteQuery хранит данные в структуре:
pages — массив загруженных страницpageParams — параметры, использованные для каждой
страницыКаждый новый запрос добавляет либо страницу в конец
(fetchNextPage), либо в начало
(fetchPreviousPage).
Ключевой момент: TanStack Query не предполагает, что данные обязательно идут только в одном направлении. Это позволяет строить двунаправленные списки, если API поддерживает курсоры в обе стороны.
Двунаправленная загрузка почти всегда опирается на курсоры:
nextCursor — указатель на следующую страницуprevCursor — указатель на предыдущую страницуОтвет API обычно выглядит так:
{
items: [...],
nextCursor: "abc123",
prevCursor: "xyz987"
}
Эта структура позволяет двигаться в обе стороны без пересчёта offset-пагинации.
import { useInfiniteQuery } from '@tanstack/react-query'
const fetchMessages = async ({ pageParam }) => {
const res = await fetch(`/api/messages?cursor=${pageParam ?? ''}`)
return res.json()
}
const query = useInfiniteQuery({
queryKey: ['messages'],
queryFn: fetchMessages,
initialPageParam: null,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
getPreviousPageParam: (firstPage) => firstPage.prevCursor ?? undefined,
})
В этой конфигурации:
getNextPageParam отвечает за движение внизgetPreviousPageParam отвечает за движение вверхinitialPageParam задаёт стартовую точкуМетод fetchPreviousPage активирует загрузку данных
“вверх”:
await query.fetchPreviousPage()
TanStack Query добавляет новую страницу в начало массива
pages.
Важно учитывать, что UI должен компенсировать изменение высоты списка, иначе пользователь потеряет позицию прокрутки.
Стандартный сценарий:
await query.fetchNextPage()
Данные добавляются в конец:
pages = [page1, page2, page3]
Этот механизм используется для бесконечной прокрутки вниз.
При добавлении страниц в начало возникает проблема с “прыжком” интерфейса. Решение — фиксация scroll offset.
Типовой подход:
fetchPreviousPageПример логики:
const container = document.getElementById('list')
const beforeHeight = container.scrollHeight
await query.fetchPreviousPage()
const afterHeight = container.scrollHeight
container.scrollTop += (afterHeight - beforeHeight)
Чат-интерфейсы часто используют инвертированную ось:
В этом случае:
fetchNextPage может означать загрузку старых сообщений
вверхfetchPreviousPage может использоваться редко или вообще
отсутствоватьСтруктура данных остаётся той же, но логика UI инвертируется.
TanStack Query хранит данные постранично, но UI обычно требует плоский список:
const messages = query.data?.pages.flatMap(page => page.items) ?? []
При двунаправленной загрузке важно учитывать:
При частых загрузках вверх и вниз возможны пересечения данных.
Стратегия:
const uniqueItems = []
const seen = new Set()
for (const page of query.data.pages) {
for (const item of page.items) {
if (!seen.has(item.id)) {
seen.add(item.id)
uniqueItems.push(item)
}
}
}
Это особенно важно при нестабильных курсорах или eventual consistency на сервере.
Двунаправленные списки часто зависят от контекста:
Пример:
queryKey: ['messages', chatId, filters]
Любое изменение ключа полностью пересоздаёт кеш, что предотвращает смешивание потоков данных.
В двунаправленных списках важно ограничивать:
fetchPreviousPagefetchNextPageТипичная ошибка — одновременная загрузка в обе стороны при быстром скролле.
Защита:
if (query.isFetching || query.isFetchingNextPage || query.isFetchingPreviousPage) {
return
}
Для автоматической подгрузки применяются sentinel-элементы:
Пример логики:
const topObserver = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
query.fetchPreviousPage()
}
})
const bottomObserver = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
query.fetchNextPage()
}
})
Корректная двунаправленная загрузка невозможна без строгой серверной контрактности:
При нарушении этих условий возникают:
При двунаправленной загрузке важно учитывать, что:
invalidateQueries может перезагрузить только первую
страницуДля полной синхронизации используется:
queryClient.invalidateQueries({ queryKey: ['messages'] })
Но в больших лентах это может быть дорого, поэтому часто применяется частичное обновление.
При больших объёмах данных используется виртуализация (например, react-window или react-virtual):
Особенности интеграции:
При двунаправленной загрузке виртуализация усложняется из-за prepend-операций, которые меняют смещение элементов.
Внутренне структура выглядит как:
queryCache
└── messages
├── page1
├── page2
├── page3
Каждая страница независима, но объединяется в UI слое.
Это позволяет:
При двунаправленных списках важно учитывать:
Это поведение снижает нагрузку, но требует явной синхронизации при критически свежих данных (например, чаты или уведомления).
В таймлайнах часто используется стратегия:
Это упрощает серверную реализацию и делает двунаправленную модель предсказуемой.
При частичном обновлении данных может потребоваться:
В таких случаях TanStack Query используется как кеш-слой, а не как источник истины для структуры страниц.