29 мая 2025 г.·7 мин чтения

Курсорная пагинация в админ-инструментах, которая сохраняет работу кнопки «Назад»

Курсорная пагинация в админ-инструментах помогает удерживать списки стабильными с фильтрами, сортировкой и глубокими ссылками, даже когда записи меняются между запросами.

Курсорная пагинация в админ-инструментах, которая сохраняет работу кнопки «Назад»

Почему список постоянно скачет

Админский список почти никогда не стоит на месте. Появляются новые записи, люди редактируют старые, а некоторые строки исчезают. Из-за этих постоянных изменений обычный просмотр по принципу «страница 3» часто кажется ненадёжным.

Представьте входящие обращения в поддержку, отсортированные по новизне. Вы открываете страницу 2, просматриваете несколько тикетов, потом открываете один из них и нажимаете кнопку браузера «Назад». Пока вас не было, пришло ещё пять тикетов. Записи, которые раньше были в начале страницы 2, теперь могли опуститься ниже, а часть записей со страницы 1 — переместиться на страницу 2. Номер страницы остался тем же, а данные уже другие.

Правки создают ту же проблему. Если список сортируется по «последнему обновлению», одна изменённая запись может сразу подняться наверх. Если сортировка идёт по приоритету, статусу или баллу, одно изменение поля способно переместить строку в другую часть списка. Вы возвращаетесь и видите дубли, пропуски или и то и другое. Большинству пользователей кажется, что сломалась кнопка «Назад», хотя на самом деле сдвинулся сам список.

Удаления вызывают более тихую версию той же ошибки. Одна строка исчезает, всё, что было ниже, поднимается вверх, и на странице появляется пустое место, пока следующий запрос не заполнит его другой записью. Это звучит не слишком серьёзно, но ощущение, что пользователь вернулся в то же место, уже теряется.

Нумерация страниц только усугубляет ситуацию, потому что она указывает на движущийся срез живого списка. Для статичного контента это работает нормально. Но в загруженных админских интерфейсах, где записи меняются каждую минуту, «страница 3» — это не место. Это просто то, что случайно оказалось в этом слоте в данный момент.

Именно это и пытается решить курсорная пагинация в админ-инструментах. Цель не только в том, чтобы листать быстрее. Цель в том, чтобы список оставался понятным после добавлений, правок, удалений и нажатия кнопки браузера «Назад».

Что должен означать курсор

Курсор должен обозначать точку в отсортированном списке. Он не должен означать «страница 4» или «элементы с 61 по 80». Номера страниц смещаются, как только кто-то создаёт, редактирует или удаляет запись. Граница не смещается так же легко.

Если список сортируется по created_at по убыванию, курсор должен хранить последнее видимое значение created_at из текущей порции. Это подсказывает следующему запросу, с какого места продолжать. Но одного этого значения мало, потому что одинаковый timestamp может встречаться у многих строк.

Добавьте уникальный тай-брейкер, обычно ID записи. Тогда граница будет не «после 10:03:21», а «после 10:03:21, запись 84721». Эта маленькая деталь убирает классическую ошибку, когда одна строка показывается дважды, а другая пропадает.

Один курсор, два направления

Движение вперёд и движение назад — это разные действия. Это стоит явно хранить в состоянии пагинации. Курсор для «next» должен браться из последней строки на экране, а курсор для «previous» — из первой строки на экране.

Представьте список в админке, отсортированный сначала по новым записям. Если экран заканчивается так:

  • created_at: 2026-04-12 10:03:21
  • id: 84721

Следующий запрос должен искать строки старше этой границы, с правилом для равных timestamp. Возврат назад должен использовать первую строку на экране и обратное сравнение. Многие команды получают предыдущую порцию в обратном порядке, а потом переворачивают её перед рендером, чтобы у пользователя список выглядел так же.

Хороший курсор может быть непрозрачным токеном, но внутри у него всё равно должен быть понятный смысл. Он должен содержать значение поля сортировки, тай-брейкер и направление. Этого достаточно, чтобы продолжить с устойчивой точки.

Считайте курсор командой «начать после этой строки» или «начать перед этой строкой». Не считайте его номером страницы, смещением или обещанием, что следующий экран навсегда покажет те же записи. Записи могут меняться между запросами. Задача курсора проще: провести список по стабильному порядку без дублей, дыр и сломанной кнопки «Назад».

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

Курсор работает только тогда, когда порядок списка остаётся предсказуемым. Если человек может посмотреть на таблицу и понять, почему одна строка стоит выше другой, он больше доверяет списку. Во многих админских интерфейсах сортировка по created_at или submitted_at подходит лучше всего, потому что пользователи и так мыслят в таком порядке.

Выбирайте поле сортировки, которое соответствует задаче на экране. Входящие обращения в поддержку обычно лучше всего работают с самыми новыми тикетами сверху. Очередь на проверку может лучше подходить для старых записей сверху, чтобы сотрудники проходили работу по порядку.

Некоторые поля выглядят полезными, но создают проблемы. updated_at — частый пример. Заметка, изменение статуса, действие бота или фоновая синхронизация могут сдвинуть запись, пока кто-то листает результаты. Тогда страница 2 уже не будет той же самой страницей 2, и кнопка браузера «Назад» начнёт казаться сломанной, хотя сама история работает нормально.

Для сортировки по умолчанию лучше брать более спокойные поля:

  • created_at, если записи появляются один раз и потом остаются на месте
  • submitted_at, если время отправки важнее последующих правок
  • priority, только если оно меняется редко и пользователи это понимают

Есть ещё одно правило, не менее важное: всегда добавляйте ID записи в качестве последнего тай-брейкера. Две строки могут иметь одинаковый timestamp, особенно после импорта или массовых действий. Если запрос заканчивается на ORDER BY created_at DESC, база может вернуть строки с одинаковым значением в другом порядке при следующем запросе. Вместо этого используйте ORDER BY created_at DESC, id DESC. Так у каждой записи будет своё точное место.

Оставляйте порядок по умолчанию одинаковым между сессиями, если пользователь сам не выбрал другой. Люди привыкают к спискам. Если один и тот же экран в понедельник открывается с одной сортировкой, а в пятницу — с другой, они очень быстро теряют ориентир.

С этого и начинается стабильный список. Если сортировку можно объяснить одной фразой, а ID снимает неоднозначность, у курсора есть прочная основа. Если порядок меняется каждые несколько секунд, никакая схема пагинации уже не спасёт.

Держите фильтры и поиск внутри состояния пагинации

Курсор имеет смысл только для одного конкретного вида списка. Если пользователь меняет статус, диапазон дат, владельца или текст поиска, старый курсор указывает уже на другой набор результатов. Именно тогда появляются дубли, пропущенные строки и пустые экраны.

Храните всё состояние списка в URL, а не только курсор. Когда браузер сохраняет это состояние в истории, кнопка «Назад» может восстановить тот же экран, а не приблизительную догадку.

Держите вместе следующие значения:

  • поле сортировки
  • направление сортировки
  • активные фильтры
  • текст поиска
  • текущий курсор

Этот набор должен передаваться как единое целое в каждом запросе. Если сортировка хранится в одном месте, фильтры — в другом, а курсор только в памяти, список становится трудно восстановить и ещё труднее отлаживать.

Сбрасывайте курсор каждый раз, когда меняется фильтр. Сбрасывайте его и тогда, когда меняется текст поиска. Поиск особенно легко испортить, потому что даже один лишний символ может сильно изменить набор результатов. Безопаснее всего снова начать с первой страницы.

Сервер тоже должен защищаться. Запрос с курсором от другого набора фильтров не должен проходить молча. Можно встроить в курсор короткий отпечаток текущих сортировки, направления, фильтров и поиска или хранить эти данные рядом с токеном курсора. Если входящий запрос не совпадает, отвергайте курсор и отправляйте первую страницу для нового состояния.

Это правило особенно важно в курсорной пагинации в админ-инструментах, где люди весь день прыгают между сохранёнными представлениями. Финансовый администратор может искать счета по имени клиента, потом переключиться на просроченные и нажать «Назад». Если в каждой записи истории хранится полное состояние пагинации, экран возвращается к тому же отфильтрованному списку вместо смешанного или устаревшего результата.

Поддерживайте глубокие ссылки и историю браузера

Проведите проверку пагинации
Проверьте порядок сортировки, тай-брейкеры, фильтры и состояние истории вместе с Oleg.

Хороший админский список должен переживать обновление страницы, копирование адреса и кнопку браузера «Назад». Если кто-то откроет сохранённый вид завтра, он должен попасть на те же фильтры, ту же сортировку и то же место в списке — настолько близко, насколько это позволяют данные.

В курсорной пагинации в админ-инструментах текущие параметры вида нужно хранить в URL. Обычно это поле сортировки и его направление, активные фильтры, текст поиска, размер страницы и текущий токен курсора. Если ваш курсор уже содержит значение последней строки и ID для тай-брейка, отдельные поля URL для этих внутренних деталей не нужны. Нужен сам полный токен, который сервер сможет прочитать снова.

В большинстве случаев достаточно такого URL:

  • sort=created_at.desc
  • status=open
  • q=refund
  • limit=50
  • after=eyJjcmVhdGVkX2F0Ijoi..."

Это покрывает глубокие ссылки. Для возврата назад нужен ещё один элемент. Когда пользователь идёт вперёд, сохраняйте каждый предыдущий курсор в состоянии истории браузера, а не внутри текущего курсора. Тогда кнопка «Назад» сможет восстановить прежний запрос напрямую, вместо того чтобы пытаться развернуть токен, который умеет двигаться только вперёд.

Выделение строки держите отдельно от состояния пагинации. Если пользователь кликает по строке 8472, это выделение не должно менять курсор или токен страницы. Считайте его отдельной частью состояния, например selected=8472, или храните в локальном состоянии интерфейса. Позиция списка и выбранная строка решают разные задачи.

Когда точный срез меняется

Записи будут двигаться. Новые строки приходят, старые редактируются, некоторые исчезают. Если сохранённый URL больше не указывает на тот же самый срез, не делайте вид, что ничего не изменилось. Покажите короткое сообщение в интерфейсе, например: «Результаты изменились с момента открытия этого вида. Некоторые строки могли переместиться».

Такое короткое уведомление сильно снижает путаницу. Оно также показывает пользователю, что фильтры и сортировка всё ещё работают, даже если страница уже не является идеальным снимком.

Если вы построите систему так, ссылка будет открывать тот же вид, обновление страницы сохранит контекст, а кнопка браузера «Назад» будет ощущаться нормально, а не сломано.

Постройте поток запроса по шагам

Относитесь к URL как к записи текущего вида. Когда страница загружается, сначала прочитайте сортировку, активные фильтры, текст поиска и курсор из query string, и только потом обращайтесь к базе данных. Так вы получите глубокие ссылки, которые открывают тот же вид, и дадите кнопке браузера «Назад» возвращать не догадку, а реальное состояние.

Обычно чистый поток запроса выглядит так:

  1. Разберите URL в объект состояния пагинации. Оставьте его компактным: поле сортировки, направление сортировки, значения фильтров, поисковый запрос, размер страницы и курсор.
  2. Проверьте каждую часть этого состояния. Разрешайте только известные поля сортировки и названия фильтров. Если значение неверное, замените его безопасным по умолчанию или удалите.
  3. Выполните запрос со стабильным порядком и попросите на одну строку больше, чем собираетесь показать. Если размер страницы 50, запросите 51.
  4. Верните видимые строки плюс данные курсора для следующей страницы и, если возможно, для предыдущей. Формируйте эти курсоры из первой и последней видимых строк текущего результата.
  5. Обновляйте историю браузера только после того, как меняются видимые строки. Если пользователь ввёл три буквы в поиск, но результат ещё не обновился, не добавляйте три записи в историю.

Эта лишняя строка важнее, чем кажется. Вы оставляете первые 50 строк для показа, а 51-ю используете только для одного вопроса: есть ли ещё одна страница? Так можно обойтись без отдельного запроса на подсчёт и ускорить ответ.

Предыдущие курсоры требуют той же аккуратности, что и следующие. Часто делают так: сохраняют значения граничной строки с текущей страницы, а затем выполняют тот же запрос в обратном направлении, когда пользователь возвращается назад. После этого строки снова разворачивают перед выводом.

Одна мелкая деталь сильно экономит время на разборе проблем: нормализуйте плохие входные данные до отправки ответа. Если URL просит неизвестное поле сортировки, верните исправленное состояние и дайте клиенту заменить URL. Тогда пользователи смогут копировать, сохранять и снова открывать чистую глубокую ссылку, которая каждый раз ведёт себя одинаково.

Пример: просмотр новых тикетов поддержки

Сократите дорогие переделки
Решите проблемы с пагинацией заранее с помощью точечного технического аудита.

Сотрудник поддержки открывает список тикетов с фильтрами «billing» и «open», отсортированный сначала по новым. На первой странице видны самые свежие тикеты, а вторая страница начинается не после абстрактной «страницы 1», а после конкретного курсора тикета. Именно этот маленький выбор и делает просмотр устойчивым.

Теперь сотрудник переходит на страницу 2 и начинает читать запрос на возврат. Пока он работает, приходит новый billing-тикет. Если админ-инструмент использует пагинацию по смещению, новая строка сдвигает всё вниз на одну позицию. Когда сотрудник позже нажимает кнопку браузера «Назад», на странице 2 может оказаться уже другой набор тикетов. Один тикет исчезает, другой показывается дважды, и человек теряет ориентир.

При курсорной пагинации в админ-инструментах страница 2 по-прежнему означает «элементы после этого последнего увиденного тикета в данном порядке сортировки». Запрос хранит всё состояние вместе: правило сортировки, фильтры и курсор с первой страницы. Поскольку сортировка остаётся стабильной, вторая страница открывается в том же окне тикетов, даже если выше уже появились новые.

URL здесь не менее важен. Если в адресной строке сохраняются текущие фильтры и состояние пагинации, кнопка «Назад» работает так, как люди ожидают. Сотрудник может открыть тикет, прочитать его и вернуться к тому же фрагменту списка, где всё ещё применены фильтры «billing» и «open». Коллега тоже может вставить эту глубокую ссылку в другую вкладку и попасть в то же отфильтрованное окно.

Интерфейс не должен молча перестраивать список. Лучше короткая подсказка: «Появились 3 новых тикета». Если сотрудник нажмёт на неё, инструмент перезагрузится с самого верха и покажет свежие записи. Если он её проигнорирует, текущая страница останется без изменений.

Такой баланс важнее, чем кажется. Сотрудникам обычно важнее не видеть абсолютно самую свежую строку каждую секунду, а не потерять контекст посреди проверки.

Ошибки, которые приводят к дублям и пропускам

Получите мнение Fractional CTO
Получите экспертный взгляд на состояние списка, правила сортировки и обработку истории.

Дубли и пропуски обычно возникают из-за одного неверного предположения: что список стоит на месте между запросами. В админских экранах это почти никогда не так. Появляются новые строки, старые меняются, а кто-то редактирует статус, пока другой человек листает результаты.

Одна из частых ошибок — сортировать только по created_at. У этого поля часто бывают одинаковые значения. Если у десяти записей одна и та же секунда, база может вернуть их в другом порядке при следующем запросе. Пользователь нажимает «далее» и видит строку снова. Другой пользователь не видит одну строку вообще. Добавьте второе стабильное поле, например ID записи, и храните полный порядок сортировки внутри курсора.

Ещё один лёгкий способ сломать список — повторно использовать курсор после изменения фильтров или поиска. Курсор имеет смысл только для того набора результатов, из которого он пришёл. Если пользователь переключился с тикетов «open» на тикеты «closed», старый курсор указывает уже на неправильный срез данных. Изменение фильтра, поиска или сортировки нужно считать новым списком и запрашивать первую страницу заново.

Смешивание страниц со смещением и страниц по курсору тоже создаёт тихие баги. Иногда команды оставляют смещение только для первой страницы, чтобы поддержать глубокие ссылки, а позже переключаются на курсор. Звучит безобидно, но граница между двумя системами сдвигается, когда строки добавляют или удаляют. Пользователь прыгает между двумя моделями пагинации, и список больше не сходится.

Логика предыдущей страницы тоже ломается, когда люди пытаются «просто развернуть запрос» без тех же правил сортировки. Если прямой запрос сортирует по created_at desc, id desc, то запрос назад должен аккуратно использовать ту же пару, а потом разворачивать возвращённые строки перед показом. Случайный обратный запрос — это как раз то место, где появляются дубли.

Ещё одна ошибка — скрывать изменения без уведомления. Если записи поменялись настолько, что текущему окну уже нельзя доверять, скажите об этом. Короткая фраза вроде «под этот фильтр подошли 5 новых записей» лучше, чем молча перестраивать страницу. В курсорной пагинации в админ-инструментах честность всегда важнее иллюзии стабильности.

Короткий чек-лист помогает не забыть главное:

  • Используйте стабильный тай-брейкер в каждой сортировке.
  • Сбрасывайте курсоры при изменении фильтров, поиска или сортировки.
  • Выберите одну модель пагинации и придерживайтесь её.
  • Стройте запросы для предыдущей страницы из тех же упорядоченных данных курсора.
  • Показывайте пользователям, когда свежие данные могут изменить то, что они видят.

Быстрые проверки и следующие шаги

Система пагинации выглядит нормально, пока данные не начинают двигаться под ней. Последний шаг — протестировать её так, как это делает реальный администратор: новые строки появляются, старые меняются, а в истории браузера хранится целая цепочка состояний списка.

Запустите одну короткую сессию с открытыми сетевыми логами. Несколько раз используйте те же фильтры и сортировку, а потом меняйте записи между запросами. Добавьте одну строку, которая должна попасть перед текущей страницей, измените одну строку так, чтобы она больше не подходила под фильтр, и удалите одну строку из середины. Если следующая страница показывает дубли, пропускает строку или ломает кнопку браузера «Назад», значит состояние пагинации всё ещё протекает.

Короткий чек-лист ловит большинство проблем:

  • Вставляйте, редактируйте и удаляйте записи между двумя кликами по одному списку.
  • Открывайте глубокую ссылку в новой вкладке и сравнивайте результат с исходной вкладкой.
  • Несколько раз нажимайте назад и вперёд в одной сессии, в том числе после смены фильтра.
  • Логируйте каждый отклонённый устаревший курсор с сортировкой, фильтром и временем, которые его породили.
  • Заранее решите, что покажет интерфейс, когда курсор истечёт. Простое сообщение вроде «список изменился, перезагрузите результаты» лучше молчаливого сдвига.

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

Ещё одна проверка тоже полезна: сохраните глубокую ссылку, подождите немного и откройте её в новой сессии. Если страница загружает другой фрагмент записей, значит курсор зависит от скрытого состояния, которого нет в URL.

Если эта логика всё время ломается, сторонний взгляд может сэкономить дни поиска крайних случаев. Oleg Sotnikov может проверить модель пагинации, поток запросов и обработку истории как Fractional CTO или советник. Когда эти проверки проходят, список перестаёт казаться хрупким. Администраторы начинают кликать менее осторожно, и это обычно лучший признак того, что вы всё починили.