Двунаправленная бесконечная загрузка

Двунаправленная бесконечная загрузка применяется в сценариях, где данные можно прокручивать одновременно вперёд и назад: чаты, ленты событий, журналы операций, таймлайны. В отличие от классической пагинации или одностороннего infinite scroll, здесь система должна уметь динамически подгружать страницы как при движении вниз, так и при прокрутке вверх, сохраняя целостность кеша и корректную навигацию по курсорам.

В TanStack Query эта модель реализуется через useInfiniteQuery, расширенную логикой работы с getNextPageParam и getPreviousPageParam, а также ручным управлением страницами в кеше.


Базовая модель infinite queries и её расширение

useInfiniteQuery хранит данные в структуре:

  • pages — массив загруженных страниц
  • pageParams — параметры, использованные для каждой страницы

Каждый новый запрос добавляет либо страницу в конец (fetchNextPage), либо в начало (fetchPreviousPage).

Ключевой момент: TanStack Query не предполагает, что данные обязательно идут только в одном направлении. Это позволяет строить двунаправленные списки, если API поддерживает курсоры в обе стороны.


Курсорная модель как основа двунаправленной загрузки

Двунаправленная загрузка почти всегда опирается на курсоры:

  • nextCursor — указатель на следующую страницу
  • prevCursor — указатель на предыдущую страницу

Ответ API обычно выглядит так:

{
  items: [...],
  nextCursor: "abc123",
  prevCursor: "xyz987"
}

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


Конфигурация useInfiniteQuery для двунаправленного сценария

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]

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


Стабилизация позиции скролла при prepend-операциях

При добавлении страниц в начало возникает проблема с “прыжком” интерфейса. Решение — фиксация scroll offset.

Типовой подход:

  1. Сохранение текущей позиции scrollHeight
  2. Выполнение fetchPreviousPage
  3. После рендера вычисление разницы высоты
  4. Корректировка scrollTop

Пример логики:

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) ?? []

При двунаправленной загрузке важно учитывать:

  • порядок страниц (chronological consistency)
  • отсутствие дубликатов при пересечении курсоров
  • стабильность ключей элементов

Дедупликация при перекрытии диапазонов

При частых загрузках вверх и вниз возможны пересечения данных.

Стратегия:

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 в динамических лентах

Двунаправленные списки часто зависят от контекста:

  • пользователь
  • чат
  • фильтры
  • диапазон времени

Пример:

queryKey: ['messages', chatId, filters]

Любое изменение ключа полностью пересоздаёт кеш, что предотвращает смешивание потоков данных.


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

В двунаправленных списках важно ограничивать:

  • частоту fetchPreviousPage
  • частоту fetchNextPage
  • автоматические триггеры scroll observer

Типичная ошибка — одновременная загрузка в обе стороны при быстром скролле.

Защита:

if (query.isFetching || query.isFetchingNextPage || query.isFetchingPreviousPage) {
  return
}

Использование Intersection Observer

Для автоматической подгрузки применяются sentinel-элементы:

  • верхний sentinel — загрузка предыдущих страниц
  • нижний sentinel — загрузка следующих страниц

Пример логики:

const topObserver = new IntersectionObserver(([entry]) => {
  if (entry.isIntersecting) {
    query.fetchPreviousPage()
  }
})

const bottomObserver = new IntersectionObserver(([entry]) => {
  if (entry.isIntersecting) {
    query.fetchNextPage()
  }
})

Согласование серверной и клиентской модели курсоров

Корректная двунаправленная загрузка невозможна без строгой серверной контрактности:

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

При нарушении этих условий возникают:

  • дубликаты
  • пропуски
  • скачки ленты

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

При двунаправленной загрузке важно учитывать, что:

  • invalidateQueries может перезагрузить только первую страницу
  • остальные страницы остаются в кеше до явного refetch

Для полной синхронизации используется:

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

Но в больших лентах это может быть дорого, поэтому часто применяется частичное обновление.


Стабильность списка и виртуализация

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

Особенности интеграции:

  • список должен работать с плоским массивом
  • ключи элементов обязаны быть стабильными
  • изменение страниц не должно ломать индексы

При двунаправленной загрузке виртуализация усложняется из-за prepend-операций, которые меняют смещение элементов.


Частые архитектурные ошибки

  • использование offset-пагинации вместо курсоров
  • отсутствие компенсации scroll jump при prepend
  • смешивание разных типов сортировки в одной queryKey
  • отсутствие дедупликации при частичных пересечениях
  • попытка хранить весь список в одном состоянии вне TanStack Query

Модель состояния в кеше TanStack Query

Внутренне структура выглядит как:

queryCache
 └── messages
      ├── page1
      ├── page2
      ├── page3

Каждая страница независима, но объединяется в UI слое.

Это позволяет:

  • дозагружать любую сторону
  • инвалидировать отдельные части
  • повторно использовать страницы между сессиями

Поведение при refetch on window focus

При двунаправленных списках важно учитывать:

  • refetch может обновить только первую страницу
  • остальные страницы считаются “историей”

Это поведение снижает нагрузку, но требует явной синхронизации при критически свежих данных (например, чаты или уведомления).


Работа с временными диапазонами

В таймлайнах часто используется стратегия:

  • страницы разделены по времени
  • курсоры представляют timestamp
  • загрузка вверх = более старые данные
  • загрузка вниз = более новые данные

Это упрощает серверную реализацию и делает двунаправленную модель предсказуемой.


Сложные сценарии с merge-логикой

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

  • вставка новых элементов внутрь уже загруженных страниц
  • перерасчёт границ курсоров
  • пересортировка без полной инвалидции

В таких случаях TanStack Query используется как кеш-слой, а не как источник истины для структуры страниц.