0% прочитано

Inertia.js Infinite Scroll: чому page=2 замінила вже завантажені пости

У списку постів Infinite Scroll працював до моменту завантаження page=2: нові записи приходили, але пости з першої сторінки зникали. Розбираю реальний кейс ICanUp - різницю між заміною та додаванням даних, захист від дублів, стан пагінації та правильне скидання списку при зміні фільтра, категорії або локалі.

10 вересня 2026 р. 8 хв читанняInertia.js

Infinite Scroll у ICanUp спочатку виглядав справним. Перша сторінка постів завантажувалася, я прокручувала список вниз, запит за page=2 виконувався і сервер повертав наступні записи.

Але після цього відбувалося не те, що я очікувала: пости з першої сторінки зникали, а на їхньому місці залишалися тільки записи з другої.

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

Для Infinite Scroll мало правильно отримати page=2. Потрібно явно визначити, коли нова відповідь замінює список, а коли продовжує його.

Що я побачила на сторінці

До прокручування все виглядало нормально: у списку були записи з першої сторінки.

Після завантаження наступної сторінки сервер повертав нові пости, але попередній список не зберігався.

Тобто проблема була не в тому, що page=2 не завантажилася. Проблема була в тому, що вона стала новим повним списком.

Крок

Фактично

Очікувано

Початкове завантаження

page=1

page=1

Перше прокручування

page=2 замінює page=1

page=2 додається після page=1

Наступне прокручування

видно лише останню сторінку

усі завантажені сторінки залишаються

Infinite Scroll змінює значення звичайної пагінації

Для звичайної пагінації заміна списку є нормальною поведінкою. Користувач відкриває сторінку 2 і бачить тільки записи сторінки 2.

Infinite Scroll має інший контракт. Наступна сторінка є продовженням уже показаних даних.

Саме ця різниця визначає, чи потрібно замінити локальний список, чи додати нові елементи в кінець.

Два різні контракти пагінації
Звичайна пагінація: page=1 -> [1, 2, 3, 4]page=2 -> [5, 6, 7, 8]  Infinite Scroll: page=1 -> [1, 2, 3, 4] page=2 ->[1, 2, 3, 4, 5, 6, 7, 8] page=3 ->[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]

Заміна і додавання - це різні операції

Спрощено помилку можна уявити як присвоєння кожної нової відповіді одному й тому самому списку.

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

JavaScript - Спрощена модель заміни списку
posts.value = response.data;

Для Infinite Scroll потрібна інша семантика: перша сторінка створює початковий стан, а наступні сторінки його продовжують.

Номер сторінки тут впливає не тільки на URL запиту, а й на спосіб оновлення стану.

JavaScript - Спрощена модель replace для page 1 і append для наступних
posts.value = page === 1    ? response.data    : [        ...posts.value,        ...response.data,    ];
Ці фрагменти показують сам принцип, а не копію конкретного production-класу. Для мене важливим був контракт стану: page=1 створює список, page>1 продовжує його.

Перша сторінка справді особлива

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

Коли користувач відкриває нову категорію, змінює фільтр або переходить на іншу локаль, старі результати вже не належать новому контексту.

Тому початкове завантаження має право замінити список, а продовження тієї самої вибірки - ні.

Стан пагінації є частиною списку

Одного масиву постів недостатньо. Infinite Scroll також повинен знати, яку сторінку вже завантажено і чи існує наступна.

Метадані пагінації не менш важливі за самі записи. Без них фронтенд легко може повторно запросити ту саму сторінку або продовжити завантаження після останньої.

JavaScript - Спрощений стан пагінації
const pagination = {    currentPage: response.current_page,    lastPage: response.last_page,}; const hasMore =    pagination.currentPage < pagination.lastPage;

Конкретна форма метаданих залежить від відповіді застосунку, але правило залишається однаковим.

Рішення про наступне завантаження має спиратися на поточний стан пагінації, а не просто на факт прокручування сторінки.

Після append з'являється друга проблема - дублікати

Коли список починає накопичувати сторінки, потрібно врахувати повторний запит.

Наприклад, одна й та сама сторінка може бути запитана повторно через стан інтерфейсу або кілька близьких подій завантаження.

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

JavaScript - Один зі способів захисту від дублів за Post ID
const merged = [    ...posts.value,    ...response.data,]; posts.value = [    ...new Map(        merged.map(post => [            post.id,            post,        ]),    ).values(),];

Не обов'язково використовувати саме Map. Важливіший сам інваріант: повторне отримання того самого Post не повинно створювати ще одну картку.

Потрібен захист і від паралельних завантажень

Infinite Scroll реагує на стан сторінки автоматично, тому важливо не дозволити ще одному завантаженню стартувати, поки попереднє не завершилося.

Так само немає сенсу робити новий запит, коли остання сторінка вже отримана.

JavaScript - Захист від зайвого наступного запиту
if (    loading.value    || ! hasMore.value) {    return;}

Зміна контексту повинна скидати список

Це була друга важлива частина поведінки, яку потрібно було зберегти.

Категорія, фільтр і локаль визначають, яку саме вибірку Post зараз бачить користувач.

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

Новий контекст означає нову page=1 і новий початковий список.

JavaScript - Спрощена модель скидання при зміні контексту
const contextKey = [    locale,    category,    filter,].join(':'); if (    contextKey !== currentContextKey.value) {    posts.value = [];    currentPage.value = 1;    currentContextKey.value = contextKey;}

Подія

Що робити зі списком

page=1

Замінити

page=2, page=3...

Додати

Зміна категорії

Скинути і завантажити page=1

Зміна фільтра

Скинути і завантажити page=1

Зміна локалі

Скинути і завантажити page=1

Проблема була не в самому Inertia.js

Такі помилки легко спочатку сприйняти як проблему фреймворку, тому що вони проявляються саме під час переходу або отримання нових даних.

Але в цьому випадку важливішим був контракт між серверною пагінацією і станом списку на фронтенді.

Inertia.js доставляє нові дані, але застосунок усе одно повинен знати, чи є вони новим станом сторінки, чи продовженням уже накопиченої вибірки.

Це вже не перший випадок, коли робота з Inertia.js змусила мене чіткіше визначити межу між сервером і клієнтом. Раніше я окремо розбирала ситуацію, коли метатеги були в браузері, але не в початковому HTML.

Не варто змішувати reset і append в одну неявну поведінку

Після цього кейсу мені більше подобається модель, де спосіб оновлення списку видно з контексту.

Перша сторінка або новий фільтр означають reset. Наступна сторінка тієї самої вибірки означає append.

Коли ця різниця виражена явно, поведінку простіше читати, тестувати і змінювати.

JavaScript - Явний режим оновлення списку
function applyPage(    items,    {        reset = false,    } = {},) {    posts.value = reset        ? items        : mergeUnique(            posts.value,            items,        );}

Що я перевіряю після виправлення

  • Перша сторінка формує початковий список Post.
  • Після завантаження page=2 записи з page=1 залишаються видимими.
  • Page=3 та наступні сторінки також додаються в кінець.
  • Порядок Post після кількох завантажень залишається стабільним.
  • Повторне завантаження тієї самої сторінки не створює дублів.
  • Після останньої сторінки новий запит не запускається.
  • Зміна категорії скидає попередню вибірку.
  • Зміна фільтра скидає попередню вибірку.
  • Зміна локалі не змішує Post різних мовних контекстів.

Найважливіші тести тут не про один метод

Окремий тест на функцію об'єднання масивів не доводить, що Infinite Scroll працює правильно як сценарій.

Для мене ціннішими є перевірки послідовності станів: спочатку page=1, потім page=2, потім ще одна сторінка, а окремо - зміна контексту.

Сценарії, які мають залишатися стабільними
Scenario 1:page=1-> [1, 2, 3] page=2-> [1, 2, 3, 4, 5, 6]  Scenario 2:page=1page=2change category-> old list cleared-> new page=1  Scenario 3:load page=2 twice-> no duplicated Post IDs
Після виправлення критерієм успіху для мене є не просто завантаження наступного запиту, а збереження всього списку, правильного порядку і контексту.

Чого я б не робила

  • Не вважала б успішну відповідь page=2 доказом, що Infinite Scroll працює.
  • Не використовувала б одну й ту саму заміну масиву для першої та наступних сторінок.
  • Не додавала б нові сторінки без захисту від повторних Post.
  • Не продовжувала б старий список після зміни категорії, фільтра або локалі.
  • Не визначала б наявність наступної сторінки тільки за подією прокручування.
  • Не виправляла б симптом додатковими запитами, не визначивши правила зміни стану.

Infinite Scroll - це не просто пагінація без кнопки. Він змінює сам контракт оновлення списку: наступна сторінка повинна продовжувати попередню.

Що я залишила собі на майбутнє

  • Для page=1 і наступних сторінок явно визначати різні режими оновлення.
  • Зберігати метадані пагінації разом зі станом списку.
  • Не дозволяти паралельні запити наступної сторінки.
  • Захищати накопичений список від дублів.
  • Вважати категорію, фільтр і локаль частиною контексту вибірки.
  • При зміні контексту починати з нового page=1.
  • Тестувати послідовність кількох сторінок, а не тільки один запит.

Висновок

У цьому випадку сервер повертав правильну наступну сторінку. Помилка з'являлася вже тоді, коли нові дані потрапляли у стан списку.

Для звичайної пагінації замінити page=1 на page=2 нормально. Для Infinite Scroll це руйнує основну ідею механізму.

У ICanUp потрібний контракт став простим: перша сторінка створює список, наступні його продовжують, повторні Post не дублюються, а зміна категорії, фільтра або локалі починає нову вибірку.

Саме поділ між reset і append виявився важливішим за сам механізм завантаження page=2.