Зміст
Користувач натиснув «Зберегти» в підвалі, в цеху або в поїзді. Мережа зникла на півдорозі. Звичайний застосунок на React у цю мить або показує помилку, або вдає, що запис пішов. Обидва наслідки погані: дані введені, на сервері їх немає, і після перезавантаження вкладки їх немає й локально.
offline-first — це не перевірка navigator.onLine. Це архітектура, в якій локальна база стає надійним шаром даних, а мережа — способом синхронізації. Інтерфейс оновлюється одразу, зміна переживає закриття вкладки, доходить до сервера в тому порядку, в якому її зробили, і повторне надсилання не створює другий документ.
Нижче — робоча схема на TypeScript: TanStack Query як реактивний кеш, Dexie поверх IndexedDB як локальна істина, черга вихідних операцій і сервер, який впізнає повтор за ключем. Ідея сусідить із нотаткою Наініка Мехти на Dev.to; текст нижче — самостійне складання для продакшену, включно з польовими й обліковими системами. Поруч із клієнтською архітектурою: мапа стека React, каркас Bulletproof React і перехід на TanStack.
Ключові висновки
Спочатку запис у локальну базу, потім доставка. Якщо інтерфейс уже показує рядок, якого немає в IndexedDB, перезавантаження його зітре.
Кеш TanStack Query не замінює чергу. Він відновлює екран. Функцію мутації після перезапуску процесу в базу не покласти: лишаться ключ, аргументи і статус, але не сам код надсилання.
У сутності й у операції різні ідентифікатори. Клієнтський UUID дозволяє створити об’єкт без відповіді сервера. Окремий ідентифікатор операції потрібен, щоб повтор того самого запиту не породив дубль.
Порядок усередині однієї сутності строгий, між різними сутностями можна паралелити. Оновлення не повинно обігнати створення того самого об’єкта.
Надійна доставка і розв’язання конфлікту — різні задачі. Черга гарантує, що запит дійде. Вона не вирішує, чия правка переможе, якщо той самий об’єкт змінили на іншому пристрої.
Чим offline-first відрізняється від «показати кеш»
У звичайному застосунку ланцюжок короткий. React читає і пише через TanStack Query, той ходить у HTTP, сервер кладе рядок у свою базу. Поки мережа є, схема чесна: кнопка чекає відповідь, і лише потім інтерфейс певний у результаті. Коли мережі немає, операція просто не виконується. Користувач бачить спіннер, потім помилку, і введений текст лишається тільки в пам’яті компонента.
Застосунок у режимі offline-capable уміє показати щось без мережі: попередній список, заглушку, збережений екран. Це корисно для читання. Для запису цього мало. Інспектор, комірник або лікар, який заповнив картку і закрив ноутбук, має знайти ту саму картку після відкриття. І сервер має отримати її пізніше, один раз, у правильному вигляді.
offline-first перевертає стрілку. Інтерфейс говорить із локальною базою. Локальна база тримає і самі об’єкти, і чергу того, що ще не підтверджене сервером. Мережа з’являється — черга програється. Сервер лишається авторитетом для перевірок, прав доступу й підсумкового стану, але він більше не зобов’язаний бути в мережі в ту секунду, коли людина натиснула кнопку.
React → TanStack Query → Dexie / IndexedDB
├── об’єкти
└── черга операцій → API → сервер
Чому збережений кеш запиту не довозить запис
TanStack Query уміє переживати перезавантаження, якщо кеш поклали в постійне сховище. На старті застосунок читає IndexedDB, збирає QueryClient заново, і React бачить списки й статуси, які були на екрані. Пауза мутацій теж може зберегтися: ключ, змінні, статус, метадані.
Ламається інше. Мутація — це дані плюс функція. Типовий обробник виглядає так:
mutationFn: async (data) => api.items.create(data)
Функцію в IndexedDB не серіалізувати. Після перезапуску процесу замикання мертве. У сховищі лишаються ключ і аргументи, а коду, який знає, як надіслати ці аргументи, немає. Поки застосунок не зареєструє функцію заново, відновлювати мутацію нічим.
Для цього й потрібні значення за замовчуванням для мутацій: queryClient.setMutationDefaults. Стабільний ключ на кшталт ['items', 'create'] зіставляється з функцією, яка знову жива в цьому запуску. Відновлена мутація знаходить функцію за ключем і продовжує роботу.
Порядок запуску тут жорсткий. Якщо спочатку відновити кеш і одразу викликати resumePausedMutations, а функції ще не зареєстровані, мутація не зможе продовжитися. Спочатку імпорт правил мутацій, потім клієнт, потім гідратація, потім відновлення.
Ключ не можна робити випадковим. ['create-item', Math.random()] після перезавантаження не збіжиться з жодним зареєстрованим правилом. Ключ має описувати вид операції, а не конкретний клік.
Три рівні, без яких запис губиться
Варто не обирати «або кеш, або черга», а скласти три рівні. У кожного своя гарантія.
Перший — постійний кеш запитів. Він потрібен, щоб після відкриття вкладки людина побачила останні списки і не дивилася в порожній екран, поки мережа мовчить.
Другий — відновлювані мутації. Пауза TanStack Query плюс заново зареєстрована функція закривають випадок «запит уже був у польоті, вкладку перезавантажили, функція має ожити».
Третій — довговічна черга, outbox. Кожен користувацький запис породжує локальну операцію, яка зобов’язана доїхати до сервера. Черга лежить в IndexedDB, а не в пам’яті мутації. Їй байдуже, чи живий ще той виклик useMutation, який її створив.
Повтори всередині однієї мутації слабші. Вони живуть, поки виконується конкретний виклик. Закрили вкладку, упав процес, сервер відповів із запізненням і відповідь загубилася — повтор міг цього не пережити. Черга переживає перезавантаження, падіння, закриття вкладки, зникнення мережі і тимчасову помилку сервера.
інтерфейс
↓
TanStack Query
↓
Dexie
├── дані
└── черга → працівник синхронізації → API → сервер
Чому localStorage не тягне чергу
localStorage синхронний, малий і зберігає рядки. Для пари налаштувань цього вистачає. Для списку заявок, фотографій огляду і черги операцій — ні. Запис блокує основний потік, індексу немає, вибрати «всі несинхронізовані операції цієї сутності за часом» незручно, а транзакції «об’єкт і операція разом» немає.
IndexedDB асинхронний, тримає структуровані записи й індекси, нормально живе з чергою. Dexie — тонка оболонка з типами: таблиці, версії схеми, транзакції. Не друге джерело правди, а спосіб не писати сирий API браузера на кожному екрані.
Мінімальна схема для навчального контуру обліку:
items
├── id клієнтський UUID
├── title
├── updatedAt
└── syncStatus synced | queued | syncing | failed | conflict
outbox
├── id ідентифікатор операції
├── entityId
├── action create | update | delete
├── mutationKey
├── variables
├── createdAt
├── attempts
└── status
Дві таблиці навмисно. Об’єкт — те, що бачить людина. Операція — те, що має отримати сервер. Змішувати їх в один запис означає втратити історію «створили, потім виправили, потім видалили» і не зрозуміти, що ще не доїхало.
Де лежить істина після кліку
Є два спокуси. Перший: TanStack Query — джерело істини, Dexie лише знімок кешу. Другий: Dexie — локальна істина, TanStack Query — реактивне вікно в неї.
Для читання кешу вистачає першого. Для запису потрібен другий. Інакше легко отримати екран, на якому рядок уже є, а в базі його ще немає: оптимістичне оновлення кешу пройшло, транзакція не записалася, вкладку закрили. Після відкриття рядка немає, і людина певна, що програма «з’їла» роботу.
Правильний порядок кліку:
правка
↓
транзакція Dexie: оновити об’єкт і додати операцію
↓
інвалідація або підписка TanStack Query
↓
інтерфейс
Сервер як і раніше авторитетний для правил: валідація, доступ, ідемпотентність, виявлення конфлікту. Локальна база авторитетна для того, що користувач уже зробив на цьому пристрої і що ще не підтверджено. Плутати ці дві ролі — або блокувати кнопку мережею, або показувати успіх, якого немає ні локально, ні на сервері.
Як підняти постійний кеш
Пакети, без яких схема не збирається:
npm install @tanstack/react-query
npm install @tanstack/react-query-persist-client
npm install @tanstack/query-async-storage-persister
npm install dexie
Персистер — адаптер сховища з getItem, setItem і removeItem. Його можна покласти на таблицю Dexie, не обов’язково на localStorage. Далі провайдер обгортає застосунок:
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister,
maxAge: 1000 * 60 * 60 * 24,
}}
>
<App />
</PersistQueryClientProvider>
Доба в maxAge — приклад, не норма. Важливіший зв’язок зі збирачем сміття кешу. gcTime у QueryClient має бути не меншим за строк життя збереженого кешу. Інакше персистер чесно зберігає запис, а клієнт викидає його як прострочений одразу після гідратації, і екран знову порожній.
Порядок старту, якщо дивитися на весь застосунок, а не лише на кеш:
старт
↓
читання IndexedDB
↓
збірка QueryClient і правил мутацій
↓
гідратація кешу
↓
відновлення пауз
↓
програвання черги
↓
React
Кеш тут відновлює читання. Черга відновлює зобов’язання щодо запису. Обидва кроки потрібні, і другий не випливає з першого автоматично.
Як повернути мутацію після перезавантаження
Кеш запиту — це дані. Мутація — дані і посилання на функцію. Посилання вмирає разом із процесом. Тому ключ має бути декларативним: ['items', 'create'], ['items', 'update'], ['items', 'delete']. В аргументах лежить ідентифікатор сутності і тіло. У ключі — лише вид роботи.
Реєстрація виглядає так:
queryClient.setMutationDefaults(['items', 'create'], {
mutationFn: async (variables) => api.items.create(variables),
})
Цю реєстрацію не можна ховати всередину компонента, який монтується «колись». Вона має виконатися під час створення клієнта, до гідратації. Інакше гонитва стабільна: відновлення раніше, ніж модуль із правилами встиг виконатися, і пауза зависає без функції.
Сама по собі відновлена мутація все ще крихка, якщо тіло запиту живе тільки в пам’яті TanStack Query. Черга в Dexie дублює зобов’язання навмисно. Мутація може бути зручним способом смикнути мережу, коли вона є. Черга — спосіб не втратити намір, коли мутації вже немає.
Черга операцій
outbox — локальний список того, що сервер ще не підтвердив. Для однієї сутності він може виглядати так: створити A, оновити A, видалити B. Поки мережі немає, кліки лише дописують цей список і правлять локальні об’єкти. Коли мережа з’явилася, працівник читає список і шле запити.
Чому це надійніше за повтор усередині мутації: повтор прив’язаний до живого виклику. Черга прив’язана до рядка в базі. Їй не важливо, який компонент її створив і чи жива вкладка, в якій натиснули кнопку.
Мінімальний програвач:
async function drainOutbox() {
const pending = await db.outbox.orderBy('createdAt').toArray()
for (const operation of pending) {
await processOperation(operation)
}
}
Успіх — відповідь 200 або 201: операцію видаляють із черги, локальний об’єкт позначають як синхронізований. Тимчасовий збій лишають у черзі. До тимчасових належать обрив мережі, таймаут, 408, 429, 500, 502, 503, 504. До постійних — 400, 401, 403 і помилки перевірки полів. 404 залежить від контракту: для оновлення відсутнього об’єкта це часто кінець, для видалення — іноді вже успіх. Класифікацію не можна скопіювати з чужого списку наосліп: вона випливає з вашого API.
Безкінечно повторювати 400 означає палити батарею і забивати журнал. Повтор раз на секунду на 503 схожий на саморобну відмову в обслуговуванні власного сервера. Експоненційна пауза — 1, 2, 4, 8, 16, 32 секунди — плюс випадковий зсув, щоб сто вкладок не прокинулися в одну мілісекунду. Формула паузи: база помножити на два в степені спроби, плюс випадкова добавка.
Пов’язані операції однієї сутності йдуть строго за часом. Створити, потім оновити, потім видалити. Якщо надіслати оновлення раніше за створення, сервер чесно відповість, що об’єкта немає, і черга застрягне або створить сміття. Різні сутності можна вести паралельно: в A свій ланцюжок, у B свій. Паралелити кроки всередині A не можна.
Клієнтський ідентифікатор і повтор без дубля
Класичне створення чекає, поки сервер видасть id. Без мережі ця відповідь не прийде, і локальний рядок ні до чого прив’язати. Наступна правка, видалення і сам екран роз’їдуться. Ідентифікатор потрібно видавати на клієнті: crypto.randomUUID(). Об’єкт, операція і майбутній запит посилаються на нього одразу.
Цього мало для повтору. Розділіть два ідентифікатори. entityId — хто цей об’єкт. operationId — яка це спроба змінити світ. Одна операція зберігає один operationId на всі повтори.
Сценарій втрати відповіді типовий. Клієнт послав POST, сервер створив рядок, відповідь не дійшла. Клієнт вважає, що запит не дійшов, і шле ще раз. Без ключа на сервері два об’єкти. Із заголовком Idempotency-Key: <operationId> сервер впізнає повтор і не робить другий побічний ефект: повертає попередній результат.
Навіть якщо дві вкладки помилилися і надіслали одну операцію двічі, сервер лишається останнім запором. Клієнтські замки зменшують гонитву, але не замінюють ідемпотентність.
Атомарний запис і шлях створення
Створення зобов’язане зробити два кроки разом: вставити об’єкт і вставити операцію. Якщо записати лише об’єкт, інтерфейс показує рядок, який ніхто ніколи не надішле. Якщо записати лише операцію, черга спробує надіслати те, чого на екрані немає, або надішле тіло без локального рядка для статусу.
await db.transaction('rw', db.items, db.outbox, async () => {
await db.items.add(item)
await db.outbox.add(operation)
})
Транзакція Dexie відкочує обидва записи, якщо другий не пройшов. Після неї інтерфейс має право оновитися. Не навпаки.
Повний шлях:
клік «Створити»
↓
UUID сутності і UUID операції
↓
транзакція: об’єкт + операція
↓
екран оновлюється одразу
↓
мережа є? надіслати з Idempotency-Key
немає? чекати
↓
успіх → видалити операцію, позначити synced
Оновлення і видалення йдуть тим самим коридором. Змінюється лише дія в черзі і метод HTTP: створення — POST, правка — PUT або PATCH, видалення — DELETE. Локально всі три видно одразу. Окремий «особливий» шлях для видалення без черги знову дірявить гарантію.
Видалення болючіше за створення. Жорстке стирання локального рядка до підтвердження сервера лишає діру, якщо надсилання не вдалося і об’єкт потрібно показати знову як «не видалився». М’яке видалення — поле deletedAt — ховає рядок зі списку і лишає слід для черги. Окремо вирішіть, що буде, якщо той самий об’єкт уже змінили на іншому пристрої: видалення не скасовує чужу новішу правку само собою.
Що бачить людина і коли кликати мережу
Без явного стану синхронізації offline-first відчувається як поломка: кнопка мовчить, рядок «ніби зберігся», а за годину його немає на сервері. П’ять станів закривають майже всі розмови з користувачем: synced, queued, syncing, failed, conflict. На екрані це «синхронізовано», «чекає надсилання», «надсилається», «помилка», «конфлікт». Компонент статусу читає поле об’єкта, а не глобальний прапорець «ми онлайн».
Подія online — привід спробувати чергу, не доказ, що API живий. navigator.onLine === true буває і в мережі, де ваш сервер не відповідає. Тому обробник не позначає все синхронізованим. Він викликає програвання, а успіх вирішує відповідь API.
window.addEventListener('online', () => {
void drainOutbox()
})
Ту саму спробу варто робити на старті, при поверненні на вкладку і, якщо черга довга, за таймером із тією самою зростаючою паузою. Інакше ноутбук, який жодного разу не спіймав подію online, зберігатиме операції до ручного оновлення сторінки.
Фонова синхронізація через service worker уміє доштовхнути чергу, коли вкладка вже закрита. Це прискорення, не фундамент. Підтримка в браузерах різна, у Safari на iOS обмеження жорсткі. Застосунок зобов’язаний довозити операції при наступному відкритті, навіть якщо фонового API немає.
Дві вкладки і чужа правка
Дві вкладки можуть одночасно прочитати чергу і надіслати одну операцію двічі. Лікується на клієнті замком: Web Locks API, вибір однієї провідної вкладки, BroadcastChannel, щоб решта не починали той самий прохід. Лікується на сервері ключем ідемпотентності. Друге обов’язкове, перше бажане. Якщо замок не взявся, а ключа немає, дубль уже в базі.
Конфлікт — інша вісь. Двоє людей виправили одну заявку, або цей клієнт сидів офлайн на старій версії, а сервер уже пішов уперед. Черга тут безсила: вона доставить ваш запит. Вона не знає, чи можна затерти чуже.
Практичні стратегії, і їх не можна змішувати в одну галочку «розв’язання конфліктів»:
| Підхід | Коли доречний | Ціна |
|---|---|---|
| Останній запис перемагає | Чернетки, низька ціна затирання | Тиха втрата чужої правки |
Номер версії або ETag / If-Match |
Облікові документи | 409, потрібна реакція в інтерфейсі |
| Злиття за полями | Довідники і незалежні поля | Складні правила, не для всіх сутностей |
| Розбір на сервері | Регламент, аудит | Довше, зате є журнал |
Надійна доставка не дорівнює розв’язанню конфлікту. Якщо в продукті два пристрої редагують одне й те саме, стратегія потрібна до запуску черги, а не «потім додамо».
Поле, склад і облік
Той самий малюнок живе не лише в навчальному списку задач. Він потрібен там, де людина не може втратити ввід через зв’язок: склад, обхід обладнання, чек-лист інспекції, мобільне PWA, CRM у дорозі, цех із мертвою зоною. Локальна транзакція, IndexedDB, черга, служба синхронізації, API облікової системи, PostgreSQL або інша серверна база.
Для великих документів однієї черги outbox мало. Знадобляться версії, журнал аудиту, серверна послідовність і оптимістичне блокування. Фото і підпис — теж операції черги, тільки тіло важче: їх не можна мовчки обрізати, коли скінчилося місце в браузері. Довідники має сенс качати заздалегідь і зберігати локально для читання, а назовні надсилати лише факти роботи: заявку, результат огляду, технологічну позначку.
Як різати відповідальність у такому контурі, зручно пам’ятати таблицею:
| Шар | Тримає в себе |
|---|---|
Dexie |
Довговічний стан, чергу, транзакції |
TanStack Query |
Реактивність, завантаження, кеш, життєвий цикл мутації |
| Працівник синхронізації | Програвання, повтор, порядок, паузу |
| Сервер | Перевірку, доступ, ідемпотентність, конфлікт |
Це не заміна обліковій системі і не привід тягти всю ERP у браузер. Це спосіб не втратити дію користувача на краю мережі. Про будову платформи обліку — в проєкті сучасної облікової платформи і інтеграції з обліковою системою. Про клієнт, який їде з робочого місця, — в архітектурі мобільного клієнта.
Як перевірити і що рахувати
Ручна перевірка «вимкнув вайфай, наче працює» не ловить втрату відповіді і дві вкладки. Мінімальний сценарій наскрізного тесту, його можна зібрати на Playwright: відкрити застосунок, вимкнути мережу, створити об’єкт, перезавантажити, переконатися, що об’єкт на місці, увімкнути мережу, дочекатися одного рядка на сервері.
Далі той самий коридор варто прогнати окремо: закриття вкладки, таймаут, 500, 429, повтор того самого ключа, порушення порядку всередині сутності, дві вкладки, конфлікт версії, видалення без мережі. Поки ці випадки не червоні в тесті, схема не готова, навіть якщо щасливий шлях зелений.
Метрики мають сенс як спостережуваність черги, не як обіцянка чужого відсотка. Корисно бачити частку успішних програвань, середній і максимальний час життя операції, число повторів, частку постійних відмов, частку конфліктів, скільки операцій висить і чи не роздувається база. Імена лічильників можуть бути такими: outbox.pending, outbox.failed, sync.duration, sync.retries, sync.conflicts. Це список того, що варто завести в себе, а не результат чужого стенда.
Каркас файлів, якщо збирати приклад з нуля:
src/
├── api/items.ts
├── db/database.ts
├── db/items.ts
├── db/outbox.ts
├── mutations/itemMutations.ts
├── sync/outboxWorker.ts
├── sync/retry.ts
├── sync/network.ts
├── query/client.ts
├── query/persister.ts
├── components/SyncStatus.tsx
└── App.tsx
Типові помилки
Зберігати лише кеш TanStack Query і вважати, що запис «теж зберігся». Класти серйозний стан у localStorage. Чекати ідентифікатор лише від сервера. Не ставити ключ ідемпотентності. Зберігати мутації без setMutationDefaults. Кликати resumePausedMutations до реєстрації функцій. Писати об’єкт і операцію двома незалежними await без транзакції. Надсилати ланцюжок однієї сутності паралельно. Повторювати 400, 401 і 403 без кінця. Вірити, що navigator.onLine означає живий API. Не показувати статус синхронізації. Забувати про другу вкладку.
Будь-яка одна з цих дір виглядає дрібницею на демо і втрачає документ у полі.
Часті питання
Чи досить увімкнути збереження кешу в TanStack Query?
Ні, якщо вам потрібен запис. Збереження кешу повертає екран і може повернути паузу мутації. Функцію надсилання і зобов’язання доставити зміну воно не замінює. Для запису потрібна черга в IndexedDB.
Чи можна лишити localStorage, якщо даних мало?
Для короткого чернеткового однієї форми іноді так. Щойно з’являється черга з кількох операцій, індекс за сутністю або транзакція «рядок плюс операція», localStorage починає заважати. Поріг краще не перевіряти на проді.
Що робити, якщо сервер сам видає ідентифікатори?
Для offline-first клієнт усе одно заводить свій UUID і передає його серверу як ідентифікатор або як зовнішній ключ. Чекати серверний id, щоб продовжити правку, означає знову залежати від мережі в момент кліку.
Чи потрібен service worker?
Ні як обов’язкова частина. Він може надіслати чергу у фоні. Застосунок має вміти те саме при наступному відкритті. Інакше на iOS схема мовчки не працює.
Чим доставка відрізняється від конфлікту?
Доставка відповідає на питання «чи дійшов наш запит». Конфлікт відповідає на питання «чи можна застосувати його до того, що вже лежить на сервері». Черга розв’язує перше. Версія, ETag або правила злиття розв’язують друге.
Як не створити дубль, якщо відповідь загубилася?
Один operationId на операцію і заголовок Idempotency-Key. Сервер пам’ятає оброблені ключі і на повтор повертає попередній результат, не виконуючи ефект удруге.
Що почитати далі
Клієнтський каркас навколо цієї схеми: стек React у 2026 році, каркас Bulletproof React, TanStack замість зв’язки, зібраної генератором. Встановлення вебзастосунку без зайвого кроку: елемент install. Обліковий край, куди черга врешті стукає: платформа обліку, інтеграція з обліковою системою, архітектура мобільного клієнта.
Вихідний конспект, від якого відштовхнувся цей розбір: нотатка Наініка Мехти на Dev.to.
Висновок
Надійний застосунок без мережі будується не навколо прапорця «онлайн», а навколо гарантії, що локальна операція буде доставлена. Кеш переживає перезапуск екрана. Правила мутацій заново зв’язують функцію з ключем. Черга переживає смерть вкладки. Клієнтський UUID дає ім’я об’єкту до відповіді сервера. Ключ ідемпотентності гасить повтор. Порядок не дає оновленню обігнати створення. Стратегія конфлікту вирішує, що робити, коли доставлений запит уже не єдиний.
Чотири питання, якими варто перевіряти будь-який такий контур. Де дані одразу після правки? У локальній базі. Як вони переживають перезапуск? IndexedDB і черга. Як вони потрапляють на сервер? Працівник програвання. Що буде, якщо запит пішов двічі? Ключ операції і дедуплікація на сервері. Якщо на будь-яке питання відповідь «подивимось, коли з’явиться мережа», це ще не offline-first.



Коментарі