WebMriya
Назад до статей

Курсорна пагінація в React: як уникнути пропусків і дублікатів

Практичний посібник із курсорної пагінації в React: проєктування стабільних API-курсорів, запобігання пропускам і дублям та робота зі змінними даними.

React
Frontend Architecture
Software Architecture
W
Frontend Architecture & Legacy Modernization
22 жовтня 2026 р. · 5 хв читання

Користувач завантажує другу сторінку стрічки активності й бачить той самий запис двічі. Інший запис зникає. Коли стрічка не змінюється, помилка не відтворюється, тож команда фронтенду підозрює кешування, а команда API — сортування. Часто справжня причина в тому, що інтерфейс запитує «сторінку 2» колекції, яка змінилася між запитами.

Чому зміщується нумерація сторінок

Пагінація за зміщенням запитує конкретну позицію: «пропустити 20 записів і повернути наступні 10». Якщо після завантаження першої сторінки на початку додається новий запис, усі наступні записи зсуваються на одну позицію. Наступний запит пропускає перші 20 записів у *новому* порядку, тож може повторити вже показаний елемент. Видалення може зсунути записи в інший бік і призвести до пропуску.

ПідхідЩо означає наступний запитОсновний компроміс
ЗміщенняПропустити N записівНумеровані сторінки прості, але результати можуть зсуватися зі зміною даних
КурсорПродовжити після цієї позиціїСтабільніший перегляд, але потрібен чітко визначений порядок

Пагінація за зміщенням і далі доречна для невеликих, переважно статичних колекцій, де користувачам важливо переходити на сторінку з конкретним номером. Для стрічок, результатів пошуку, які часто змінюються, або таблиць із глибокою пагінацією курсор зазвичай надійніше позначає, де зупинився користувач.

Для курсора потрібен детермінований порядок

Курсор — це не просто «останній ID». Він позначає позицію в повному порядку сортування. Якщо записи сортуються за часом створення, у кількох записів може бути однакова часова позначка. Сортування лише за часом не визначає порядок між ними, тому курсор, побудований тільки на часі, усе ще може пропускати або повторювати рядки.

Додайте до сортування унікальний критерій для розв’язання нічиєї та використовуйте ту саму пару, щоб визначити точку продовження. Наприклад, сортуйте записи за `createdAt` за спаданням, а потім за `id` за спаданням. Тоді сервер продовжить після точної позиції `(createdAt, id)`, а не вгадуватиме, які записи мають іти перед нею.

ts
type ActivityCursor = {
  createdAt: string;
  id: string;
};

type ActivityPage = {
  items: Activity[];
  nextCursor: string | null;
};

У робочому продукті курсор зазвичай є непрозорим значенням, яке створює сервер, а не об’єктом, сформованим клієнтом. Він може кодувати поля сортування та інший контекст запиту; його слід сприймати як токен продовження, а не як фільтр, який користувач може редагувати. Ендпойнт має перевіряти курсор і відхиляти той, що не відповідає запитаному сортуванню чи фільтру.

Чітко визначте контракт API

Корисний контракт ендпойнта пояснює значення курсора, чи є наступна позиція виключною, як на нього впливають сортування й фільтри та як визначається кінець колекції. Значення `nextCursor: null` однозначне. Порожня сторінка теж може працювати, але клієнт не повинен виводити «наступної сторінки немає» з недокументованого правила.

Сервер має застосовувати однакові сортування та фільтри на кожній сторінці одного перегляду. Якщо запит змінює порядок сортування або пошуковий термін, попередній курсор більше не дійсний: почніть із першої сторінки. Для чутливих або дуже мінливих наборів даних варто вирішити, чи потрібен продукту узгоджений знімок даних, чи достатньо, щоб нові записи з’являлися лише після оновлення сторінки.

Поєднайте курсор зі станом React

У TanStack Query курсор має бути параметром сторінки, а активні фільтри й сортування — частиною ключа запиту. Так різні набори результатів не поділятимуть сторінки помилково, а бібліотека зможе керувати завантаженням і повторними спробами.

ts
const activityQuery = useInfiniteQuery({
  queryKey: ["activity", { filter, sort }],
  initialPageParam: null as string | null,
  queryFn: ({ pageParam }) =>
    fetchActivity({ filter, sort, cursor: pageParam }),
  getNextPageParam: (lastPage) =>
    lastPage.nextCursor ?? undefined,
});

Зверніть увагу: `getNextPageParam` повертає `undefined`, коли API не надає курсора. Так запит дізнається, що наступної сторінки немає. Нехай функція завантаження відповідає за передавання курсора в запит, а логіка відображення — за об’єднання записів і поточний стан завантаження.

Обробляйте зміни даних і повторні запити

Стабільний курсор допомагає серверу знайти наступну частину результатів, але не гарантує, що сторінки ніколи не перетинатимуться. Записи можуть оновлюватися чи додаватися заднім числом, або потрапляти на межу сторінки через порядок сортування на сервері чи поведінку реплік. Якщо інтерфейс не має показувати дублікати, дедуплікуйте записи за стабільним ID; не використовуйте індекс елемента в масиві як його ідентичність.

ts
const activities = activityQuery.data?.pages
  .flatMap((page) => page.items)
  .filter(
    (item, index, all) =>
      all.findIndex((candidate) => candidate.id === item.id) === index,
  ) ?? [];

Дедуплікація захищає відображення, але не може повернути запис, якого сервер не надіслав. Якщо пропускати записи неприпустимо, протестуйте логіку продовження API, додаючи та видаляючи записи між запитами сторінок. Також перевірте очікувану поведінку, якщо запис змінює позицію сортування під час перегляду: стрічка може витримати невеликі переміщення, тоді як фінансовому чи аудиторському представленню потрібна суворіша узгодженість.

  • Вимикайте або послідовно виконуйте дію «завантажити ще», поки запит сторінки вже в процесі.
  • Починайте послідовність сторінок заново після зміни фільтрів або порядку сортування.
  • Зберігайте позицію прокручування користувача під час оновлення чи додавання нових елементів.
  • Використовуйте в React стабільний ключ запису, щоб повторно отримані елементи не успадковували чужий стан компонента.

Перевіряйте граничні випадки, а не лише штатний сценарій

Тест, що завантажує дві статичні сторінки, підтверджує лише правильність з’єднання компонентів. Важливі випадки виявляють припущення щодо курсора: однакові значення сортування, новий запис між запитами, видалення біля межі сторінки, застарілий курсор після зміни фільтрів і повторна спроба після мережевої помилки.

  1. Створіть записи з однаковими основними значеннями сортування та переконайтеся, що додатковий критерій формує стабільну послідовність.
  2. Отримайте першу сторінку, додайте або видаліть запис, а потім запитайте наступну сторінку й перевірте наявність пропусків і повторів.
  3. Змініть фільтр або сортування та переконайтеся, що клієнт починає нову послідовність, а не використовує старий курсор.
  4. Змоделюйте помилку під час запиту наступної сторінки й перевірте, що повторна спроба не додає ту саму сторінку двічі.

Правильна стратегія пагінації залежить від того, як змінюється колекція і що користувач має з нею робити, а не від універсального правила, ніби курсори завжди кращі. Якщо ви створюєте інтерфейс із великим обсягом даних, у роботі WebMriya над розробкою складних вебзастосунків ми проєктуємо такі межі між API та інтерфейсом. Більше про суміжні підходи — у матеріалах про інвалідацію кешу React Query і відокремлення API-клієнтів від компонентів React.

Поширені запитання

Чи варто використовувати курсорну пагінацію для кожного списку в React?

Ні. Для невеликих, переважно статичних колекцій або інтерфейсів із нумерованими сторінками пагінації за зміщенням може бути простішою й цілком достатньою. Курсори корисні, коли записи часто змінюються або користувачі переглядають великий набір даних.

Чи гарантує курсорна пагінація, що записи ніколи не повторяться?

Сама по собі — ні. Детермінований порядок і правильна логіка продовження зменшують кількість повторів і пропусків; дедуплікація на клієнті може приховати окремі перетини сторінок, але лише сервер може гарантувати повернення потрібних записів.

Чи має фронтенд формувати курсор?

Зазвичай ні. Сприймайте курсор як непрозорий токен продовження, який повертає API, і передавайте його без змін у наступному запиті.