Курсорная пагинация

Курсорная пагинация основана на использовании курсорного значения (cursor), которое представляет собой опорную точку в наборе данных. В отличие от offset-based пагинации, где используется смещение (page, limit, offset), курсорная модель опирается на стабильный идентификатор последнего элемента предыдущей страницы.

Курсор обычно выглядит как:

  • timestamp
  • id записи
  • комбинация сортируемых полей
  • закодированная строка (opaque cursor)

Основная цель — обеспечить стабильную пагинацию при изменяющихся данных, минимизировать дубли и пропуски при вставках/удалениях.


Принципы курсорной модели

Курсорная пагинация строится на следующих свойствах:

1. Однозначная точка продолжения Курсор указывает, с какого элемента продолжить выборку.

2. Стабильная сортировка Запросы обязаны использовать фиксированный порядок, например:

  • createdAt DESC
  • id ASC

3. Сервер управляет логикой курсора Клиент не вычисляет смещения, а лишь передаёт курсор.

4. Отсутствие зависимости от общего количества записей Нет необходимости считать total count.


Курсорная пагинация в TanStack Query

В TanStack Query курсорная пагинация реализуется через useInfiniteQuery, где каждая новая порция данных определяется значением pageParam, которое в курсорной модели интерпретируется как cursor.

Ключевые элементы:

  • queryFn получает { pageParam }
  • getNextPageParam извлекает следующий cursor из ответа
  • initialPageParam задаёт стартовое значение
  • data.pages содержит все загруженные страницы

Базовая структура cursor API

Типичный ответ сервера для курсорной пагинации:

{
  "items": [
    { "id": 101, "title": "A" },
    { "id": 102, "title": "B" }
  ],
  "nextCursor": "102",
  "hasMore": true
}

Варианты:

  • nextCursor — курсор следующей страницы
  • hasMore — признак наличия данных
  • иногда курсор отсутствует, если данных больше нет

Реализация useInfiniteQuery с курсором

Базовая реализация:

import { useInfiniteQuery } fr om '@tanstack/react-query'

async function fetchPosts({ pageParam = null }) {
  const url = new URL('/api/posts', window.location.origin)

  if (pageParam) {
    url.searchParams.set('cursor', pageParam)
  }

  const res = await fetch(url)
  if (!res.ok) throw new Error('Ошибка загрузки')

  return res.json()
}

const query = useInfiniteQuery({
  queryKey: ['posts'],
  queryFn: fetchPosts,
  initialPageParam: null,
  getNextPageParam: (lastPage) => {
    return lastPage.hasMore ? lastPage.nextCursor : undefined
  }
})

Интерпретация pageParam как cursor

В курсорной модели pageParam не является номером страницы. Он выполняет роль:

  • id последнего элемента
  • timestamp последней записи
  • opaque cursor строки

Пример серверной логики:

SEL ECT * FR OM posts
WH ERE id > :cursor
ORDER BY id ASC
LIM IT 20

или для убывающей сортировки:

WHERE createdAt < :cursor
ORDER BY createdAt DESC

Формирование следующего курсора

getNextPageParam определяет, что будет передано в следующий запрос.

Пример:

getNextPageParam: (lastPage) => {
  if (!lastPage.items.length) return undefined
  return lastPage.nextCursor
}

Расширенный вариант с защитой:

getNextPageParam: (lastPage) => {
  const { items, nextCursor, hasMore } = lastPage

  if (!hasMore || items.length === 0) {
    return undefined
  }

  return nextCursor
}

Склейка страниц данных

TanStack Query автоматически объединяет страницы в структуру:

data.pages = [
  { items: [...] },
  { items: [...] }
]

Для получения плоского списка:

const items = data?.pages.flatMap(page => page.items) ?? []

Реализация бесконечного скролла

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

const {
  data,
  fetchNextPage,
  hasNextPage,
  isFetchingNextPage
} = useInfiniteQuery({
  queryKey: ['posts'],
  queryFn: fetchPosts,
  initialPageParam: null,
  getNextPageParam: (lastPage) => lastPage.nextCursor
})

Событие скролла:

useEffect(() => {
  function onScroll() {
    const nearBottom =
      window.innerHeight + window.scrollY >= document.body.offsetHeight - 200

    if (nearBottom && hasNextPage && !isFetchingNextPage) {
      fetchNextPage()
    }
  }

  window.addEventListener('scroll', onScroll)
  return () => window.removeEventListener('scroll', onScroll)
}, [hasNextPage, isFetchingNextPage, fetchNextPage])

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

Некоторые API поддерживают движение назад:

Ответ сервера:

{
  "items": [],
  "nextCursor": "200",
  "prevCursor": "120"
}

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

getPreviousPageParam: (firstPage) => {
  return firstPage.prevCursor ?? undefined
}

И включение:

useInfiniteQuery({
  queryKey: ['posts'],
  queryFn: fetchPosts,
  initialPageParam: null,
  getNextPageParam: (lastPage) => lastPage.nextCursor,
  getPreviousPageParam: (firstPage) => firstPage.prevCursor
})

Сброс и инвалидирование данных

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

При добавлении новых записей:

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

Это приводит к:

  • очистке кеша страниц
  • повторной загрузке с начального курсора
  • пересборке последовательности страниц

Изменение сортировки и ключа запроса

Курсор зависит от порядка сортировки.

Неправильный подход:

queryKey: ['posts']

Правильный подход:

queryKey: ['posts', sortOrder]

Пример:

useInfiniteQuery({
  queryKey: ['posts', 'desc'],
  queryFn: fetchPosts,
  initialPageParam: null,
  getNextPageParam: (lastPage) => lastPage.nextCursor
})

При изменении сортировки старые курсоры становятся невалидными.


Дедупликация данных

При нестабильных курсорах возможны дубликаты.

Решение на клиенте:

const uniqueItems = Array.fr om(
  new Map(
    data.pages
      .flatMap(p => p.items)
      .map(item => [item.id, item])
  ).values()
)

Ошибки курсорной модели

1. Нестабильный порядок сортировки

Если сортировка не зафиксирована:

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

2. Использование offset вместе с cursor

Смешивание моделей приводит к:

  • рассинхронизации страниц
  • неправильным результатам

3. Генерация cursor на клиенте

Курсор должен формироваться только на сервере.


Оптимизация запросов

Ограничение параллельных запросов

enabled: !!userId

Кеширование страниц

TanStack Query хранит каждую страницу отдельно, но объединяет их логически через queryKey.


Инвалидация отдельных страниц

Иногда требуется обновить только часть данных:

queryClient.invalidateQueries({
  queryKey: ['posts'],
  refetchPage: (page, index) => index === 0
})

Работа с серверными курсорами разного типа

Числовой cursor

cursor = 102

Timestamp cursor

cursor = 1716540000000

Opaque cursor

cursor = "eyJpZCI6MTAyLCJ0cyI6MTcxNjU0..."

Клиенту не важно содержимое, только передача.


Типизация (TypeScript-ориентированная модель)

type Post = {
  id: number
  title: string
}

type ApiResponse = {
  items: Post[]
  nextCursor: string | null
  hasMore: boolean
}

Структура данных внутри useInfiniteQuery

{
  pages: [
    {
      items: [...]
      nextCursor: "120"
    },
    {
      items: [...]
      nextCursor: "140"
    }
  ],
  pageParams: [null, "120"]
}

pageParams отражает историю курсоров.


Практическая модель сервер + клиент

Сервер:

  • принимает cursor
  • применяет сортировку
  • возвращает items + nextCursor

Клиент:

  • передаёт pageParam
  • хранит историю в pages
  • запрашивает следующую порцию через fetchNextPage

Поведение при изменении данных во время скролла

Если новые записи добавляются в начало:

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

Решение:

  • инвалидировать query
  • либо использовать snapshot-модель на сервере

Сравнение устойчивости

Курсорная модель обеспечивает:

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

в отличие от offset-подхода, который деградирует при росте таблиц.