Курсорная пагинация основана на использовании курсорного
значения (cursor), которое представляет собой опорную точку в
наборе данных. В отличие от offset-based пагинации, где используется
смещение (page, limit, offset),
курсорная модель опирается на стабильный идентификатор последнего
элемента предыдущей страницы.
Курсор обычно выглядит как:
Основная цель — обеспечить стабильную пагинацию при изменяющихся данных, минимизировать дубли и пропуски при вставках/удалениях.
Курсорная пагинация строится на следующих свойствах:
1. Однозначная точка продолжения Курсор указывает, с какого элемента продолжить выборку.
2. Стабильная сортировка Запросы обязаны использовать фиксированный порядок, например:
createdAt DESCid ASC3. Сервер управляет логикой курсора Клиент не вычисляет смещения, а лишь передаёт курсор.
4. Отсутствие зависимости от общего количества записей Нет необходимости считать total count.
В TanStack Query курсорная пагинация реализуется через
useInfiniteQuery, где каждая новая порция данных
определяется значением pageParam, которое в курсорной
модели интерпретируется как cursor.
Ключевые элементы:
queryFn получает { pageParam }getNextPageParam извлекает следующий cursor из
ответаinitialPageParam задаёт стартовое значениеdata.pages содержит все загруженные страницыТипичный ответ сервера для курсорной пагинации:
{
"items": [
{ "id": 101, "title": "A" },
{ "id": 102, "title": "B" }
],
"nextCursor": "102",
"hasMore": true
}
Варианты:
nextCursor — курсор следующей страницыhasMore — признак наличия данныхБазовая реализация:
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 не является номером
страницы. Он выполняет роль:
Пример серверной логики:
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()
)
Если сортировка не зафиксирована:
Смешивание моделей приводит к:
Курсор должен формироваться только на сервере.
enabled: !!userId
TanStack Query хранит каждую страницу отдельно, но объединяет их
логически через queryKey.
Иногда требуется обновить только часть данных:
queryClient.invalidateQueries({
queryKey: ['posts'],
refetchPage: (page, index) => index === 0
})
cursor = 102
cursor = 1716540000000
cursor = "eyJpZCI6MTAyLCJ0cyI6MTcxNjU0..."
Клиенту не важно содержимое, только передача.
type Post = {
id: number
title: string
}
type ApiResponse = {
items: Post[]
nextCursor: string | null
hasMore: boolean
}
{
pages: [
{
items: [...]
nextCursor: "120"
},
{
items: [...]
nextCursor: "140"
}
],
pageParams: [null, "120"]
}
pageParams отражает историю курсоров.
Сервер:
cursoritems + nextCursorКлиент:
pageParampagesfetchNextPageЕсли новые записи добавляются в начало:
Решение:
Курсорная модель обеспечивает:
в отличие от offset-подхода, который деградирует при росте таблиц.