Офлайн-First і синхронізація
Офлайн-First і синхронізація
Відкриття: коли мережі немає, а працювати треба
Уявіть: ви в метро, їдете на роботу, і вирішуєте додати нову поїздку до щоденника. Натискаєте «Створити», заповнюєте назву, дату — і лише тоді помічаєте: мережі немає. Вебсайт у цей момент показав би помилку «No connection» і попросив спробувати пізніше. Але мобільний застосунок може вчинити інакше: зберегти вашу поїздку локально і відправити на сервер автоматично, коли з'явиться інтернет. Ви навіть не помітите проблеми — просто продовжуєте працювати.
Такий підхід називають офлайн-first (offline-first): застосунок спочатку зберігає дані локально на пристрої, а вже потім намагається синхронізувати їх із сервером. Користувач не чекає на мережу — він бачить свої зміни одразу, а застосунок у фоні «домовляється» з API.
У попередній статті ми розглянули, як зберігати дані локально через MMKV і SQLite. Тепер з'ясуємо:
- Як ставити зміни в чергу (outbox pattern), коли API недоступний.
- Як показувати статус синхронізації (
pending,synced,error) на кожному record. - Що робити з конфліктами, коли сервер повертає іншу версію даних.
- Як побудувати UX для офлайн-режиму — banner «Офлайн», retry-кнопки, оптимістичні оновлення.
Наприкінці статті ми напишемо міні-проєкт «Офлайн-чеклист» — простий TODO-список, який додає завдання локально, ставить їх у чергу і синхронізує з mock API, коли з'являється мережа. Потім у Nomad реалізуємо повноцінну систему синхронізації поїздок і місць з полями syncStatus і syncError.
Частина 1. Теорія офлайн-синхронізації
1.1. Навіщо офлайн-first у мобільних застосунках
У веб-застосунках зазвичай діє модель online-first: користувач натискає «Зберегти», браузер робить POST-запит на сервер, і лише після відповіді 200 OK показується успіх. Якщо мережі немає або сервер недоступний — з'являється помилка, і користувач чекає.
У мобільних застосунках цей підхід працює гірше через кілька причин:
| Причина | Чому це важливо на телефоні |
|---|---|
| Нестабільна мережа | Користувач переміщується між Wi-Fi, LTE, метро, ліфтом — з'єднання може зникнути в будь-який момент. |
| Повільний інтернет | У роумінгу або на околиці міста запит може йти кілька секунд — чекати на відповідь сервера дратує. |
| Батарея | Синхронні запити тримають екран активним; фонова черга дозволяє об'єднати кілька операцій в один batch. |
| UX-очікування | Користувачі звикли, що телефон «просто працює» — як у native-застосунках (Notes, Photos), які зберігають локально і синхронізують сховано. |
Офлайн-first вирішує ці проблеми:
- Зміни зберігаються локально (MMKV, SQLite, Realm) одразу — без чекання на сервер.
- UI оновлюється миттєво — користувач бачить свою нову поїздку в списку через мілісекунди.
- Фонова черга (outbox) автоматично відправляє зміни на сервер, коли з'являється мережа.
- Статус синхронізації показує, чи дані вже на сервері, чи ще в черзі.
1.2. Outbox Pattern — черга змін
Outbox (аутбокс, «вихідна черга») — це локальна таблиця або список операцій, які треба відправити на сервер. Кожна операція (створення поїздки, оновлення місця, видалення фото) зберігається як запис у черзі з полями:
type OutboxItem = {
id: string; // UUID локального запису
operation: 'CREATE' | 'UPDATE' | 'DELETE';
resource: 'trip' | 'place' | 'photo';
payload: unknown; // дані для відправки (JSON)
createdAt: number; // timestamp створення (для сортування)
retryCount: number; // скільки разів намагалися відправити
lastError?: string; // текст останньої помилки (якщо була)
};
Як працює outbox:
- Користувач створює поїздку → застосунок одразу записує її в локальну базу SQLite з
syncStatus: 'pending'. - Одночасно додається запис у таблицю
outbox:{ operation: 'CREATE', resource: 'trip', payload: { title, startDate, ... } }. - Фоновий процес (useEffect, setInterval, або нативний WorkManager/BackgroundTasks) періодично перевіряє outbox.
- Якщо є мережа — бере перший запис з черги, робить POST/PATCH/DELETE на API.
- Якщо сервер відповідає
200 OK— видаляє запис з outbox, оновлюєsyncStatus: 'synced'у локальній базі, записуєserverId(якщо сервер повернув ID). - Якщо помилка (timeout, 500, 409 conflict) — збільшує
retryCount, записуєlastError, залишає запис у черзі для наступної спроби.
Переваги outbox:
- Гарантія доставки: навіть якщо застосунок закриють або телефон вимкнеться, черга лишається в локальній базі — при наступному запуску синхронізація продовжиться.
- Порядок операцій: outbox обробляє записи за часом створення (
createdAt) — спочатку старі, потім нові. Це важливо, якщо одна операція залежить від іншої (наприклад, спочатку створити поїздку, потім додати до неї місце). - Retry-логіка: якщо запит не вдався, запис лишається в черзі, і застосунок спробує знову через кілька секунд або хвилин (exponential backoff).
1.3. syncStatus — стани синхронізації
Кожен запис у локальній базі (поїздка, місце, фото) має поле syncStatus, яке показує, чи дані вже на сервері:
type SyncStatus =
| 'pending' // локально створено, але ще не відправлено
| 'syncing' // зараз відправляється на сервер
| 'synced' // успішно синхронізовано, є serverId
| 'error'; // помилка синхронізації (conflict, server error)
Життєвий цикл запису:
- Створення офлайн: користувач додає поїздку без мережі →
syncStatus: 'pending',serverId: null. - Початок синхронізації: outbox-worker бере запис з черги →
syncStatus: 'syncing'. - Успіх: сервер повертає
201 Createdз{ id: "srv-123" }→syncStatus: 'synced',serverId: "srv-123", запис видаляється з outbox. - Помилка: сервер повертає
409 Conflictабо timeout →syncStatus: 'error',syncError: "Conflict: trip with this title exists", запис лишається в outbox для retry.
У UI це виглядає так:
| syncStatus | Що бачить користувач |
|---|---|
pending | Іконка «хмаринка з годинником» або сірий badge «Не синхронізовано» |
syncing | Спінер або анімація «відправляється» |
synced | Зелена галочка або просто немає badge (нормальний стан) |
error | Червона іконка + кнопка «Повторити» + текст помилки в деталях |
syncStatus (чи дані на сервері) з loadingStatus RTK Query (чи запит іде зараз). syncStatus — це властивість даних у локальній базі, а isLoading RTK Query — це стан UI під час fetch. Офлайн-first працює навпаки: спочатку дані в базі, потім запит у фоні.1.4. Конфлікти (conflicts) і стратегії розв'язання
Конфлікт виникає, коли:
- Користувач змінює поїздку офлайн (локально
version: 3,syncStatus: 'pending'). - Інший користувач (або той самий на іншому пристрої) змінює ту саму поїздку онлайн — сервер має
version: 4. - Коли перший користувач синхронізується, сервер повертає
409 Conflictабо412 Precondition Failed— «ваша версія застаріла».
Три стратегії розв'язання конфліктів:
| Стратегія | Що робить застосунок | Коли доречно |
|---|---|---|
| Last Write Wins (LWW) | Приймає останню зміну за часом (updatedAt); сервер затирає старішу версію. | Прості дані (налаштування, профіль), де конфлікти рідкі і не критичні. |
| Server Wins | Завжди приймає серверну версію; локальні зміни скидаються. | Критичні дані (баланс, inventory), де сервер — єдине джерело істини. |
| Manual Merge | Показує користувачу обидві версії (локальну і серверну), просить вибрати або об'єднати вручну. | Текстові документи, складні об'єкти (CRM-запис з 20 полями), де втрата даних неприпустима. |
У Nomad ми використовуємо Last Write Wins для простоти: якщо сервер повертає 409, застосунок порівнює updatedAt локального і серверного запису — новіший виграє. Якщо серверний новіший — застосунок замінює локальні дані, outbox-запис видаляється. Якщо локальний новіший — надсилаємо force-update з ?force=true.
Приклад конфлікту в Nomad:
- Користувач офлайн редагує поїздку
"Київ"→"Київ + Львів",updatedAt: 2026-08-11T10:00:00Z. - Інший пристрій онлайн редагує ту саму поїздку →
"Київ + Одеса",updatedAt: 2026-08-11T10:05:00Z(на 5 хвилин пізніше). - Перший пристрій синхронізується, сервер повертає
409+ серверну версію. - Застосунок порівнює: серверна
10:05> локальна10:00→ приймає серверну, локальна зміна губиться (або показуємо toast «Серверна версія новіша, ваші зміни скинуто»).
У production-застосунках часто додають operational transformation або CRDT (conflict-free replicated data types) для автоматичного злиття змін без втрати даних — але це складна тема, яку ми тут не розглядаємо. Для навчального проєкту достатньо LWW.
1.5. Offline UX — як показати користувачу, що він офлайн
Користувач має завжди розуміти, чи застосунок зараз онлайн чи офлайн, і чи його зміни синхронізовано. Погано, коли застосунок «мовчить» — людина думає, що все OK, а насправді дані не на сервері і можуть загубитися при видаленні застосунку.
Елементи offline UX:
| Елемент | Що показує | Де розміщувати |
|---|---|---|
| Network banner | «Офлайн-режим» (жовтий) / «Онлайн» (зелений, зникає через 2 с) | Верх екрана (sticky), над навігацією |
| Sync badge на картці | pending / syncing / error | Куточок картки поїздки/місця |
| Retry button | «Повторити синхронізацію» | Деталі запису з syncStatus: 'error' |
| Sync indicator у header | Іконка «хмаринка» з лічильником pending-записів | Права частина заголовка списку |
| Toast при переході online | «З'єднання відновлено, синхронізуємо…» | Центр екрана, 2–3 с |
Правила offline UX:
- Не блокувати UI: користувач може продовжувати працювати офлайн, навіть якщо частина даних не синхронізована.
- Показувати прогрес: якщо в outbox 10 записів, показати «Синхронізація 3/10» або прогрес-бар.
- Чітка помилка: якщо sync fail — не просто «Помилка», а «Сервер недоступний» або «Конфлікт: ця поїздка вже існує».
- Retry без зусиль: кнопка «Повторити» або автоматичний retry через 30 с / 1 хв / 5 хв (exponential backoff).
- Оптимістичні оновлення: показуємо зміни одразу (optimistic UI), а якщо сервер відхилить — відкочуємо з toast «Зміну не збережено».
1.6. Архітектура offline-first у React Native з RTK
Тепер з'єднаємо всі частини в одну систему. У застосунку на RTK + RTK Query + SQLite архітектура виглядає так:
Потік даних:
- Створення запису (офлайн):
- Користувач натискає «Створити поїздку» →
dispatch(createTrip({ title, startDate })). tripsSliceгенерує локальний UUID, записує поїздку в SQLite зsyncStatus: 'pending'.- Одночасно додає запис у
outboxтаблицю:{ operation: 'CREATE', resource: 'trip', payload: { title, startDate } }. - UI одразу показує нову поїздку в списку з badge «Не синхронізовано».
- Користувач натискає «Створити поїздку» →
- Синхронізація (фон):
- Хук
useSyncWorkerзапускається кожні 10 секунд (або на подіюonlineвід NetInfo). - Перевіряє outbox:
SELECT * FROM outbox ORDER BY createdAt LIMIT 1. - Якщо є запис — робить
fetch('POST /api/trips', { body: payload }). - Якщо
201 OK→ оновлює trip у SQLite:syncStatus: 'synced',serverId: response.id, видаляє запис з outbox. - Якщо помилка →
syncStatus: 'error',syncError: response.message, збільшуєretryCount, залишає в outbox.
- Хук
- Завантаження з сервера (онлайн):
- При запуску застосунку (або pull-to-refresh)
tripsApi.useGetTripsQuery()завантажує список із сервера. - RTK Query кешує результат, але не затирає локальні
pending-записи — вони існують лише локально, поки не синхронізуються. - Merge-логіка: якщо є запис з
serverIdу локальній базі і той самийidна сервері — оновлюємо локальну версію серверною (якщоupdatedAtсерверної новіше).
- При запуску застосунку (або pull-to-refresh)
1.7. NetInfo — визначення стану мережі
Щоб розуміти, коли запускати синхронізацію, застосунок має знати, чи є інтернет. У React Native для цього використовують бібліотеку @react-native-community/netinfo (NetInfo).
Що вміє NetInfo:
import NetInfo from '@react-native-community/netinfo';
// Одноразова перевірка
const state = await NetInfo.fetch();
console.log(state.isConnected); // true | false
console.log(state.type); // 'wifi' | 'cellular' | 'none' | ...
// Підписка на зміни
const unsubscribe = NetInfo.addEventListener(state => {
if (state.isConnected) {
console.log('Online, starting sync...');
syncWorker.run();
} else {
console.log('Offline, pausing sync');
}
});
У застосунку це виглядає так:
// hooks/useNetworkStatus.ts
import { useEffect, useState } from 'react';
import NetInfo from '@react-native-community/netinfo';
export function useNetworkStatus() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
// Початкова перевірка
NetInfo.fetch().then(state => setIsOnline(state.isConnected ?? false));
// Підписка на зміни
const unsubscribe = NetInfo.addEventListener(state => {
setIsOnline(state.isConnected ?? false);
});
return unsubscribe;
}, []);
return isOnline;
}
Використання в компонентах:
function TripsList() {
const isOnline = useNetworkStatus();
return (
<View>
{!isOnline && (
<Banner variant="warning">
Офлайн-режим. Зміни синхронізуються автоматично при появі мережі.
</Banner>
)}
{/* список поїздок */}
</View>
);
}
isConnected: true, навіть якщо на комп'ютері немає інтернету (він бачить локальну мережу). Щоб перевірити offline UX на емуляторі, вимкніть Wi-Fi в налаштуваннях емульованого пристрою (Settings → Wi-Fi → Off у симуляторі iOS; або через adb shell на Android).Це перша частина теорії (розділи 1.1–1.7). Продовжити з:
- 1.8. Exponential Backoff — розумні повторні спроби
- 1.9. Batch Sync — об'єднання операцій
- 1.10. Порівняння підходів (таблиця)
- Потім міні-проєкт + Nomad
Чи продовжувати далі теорію, чи перейти до наступної секції?
1.8. Exponential Backoff — розумні повторні спроби
Коли запит на сервер не вдається (timeout, 500, 503), застосунок має спробувати ще раз — але не одразу. Якщо сервер перевантажений або мережа нестабільна, миттєвий retry лише погіршить ситуацію. Замість цього використовують exponential backoff (експоненційна затримка) — кожна наступна спроба відбувається через удвічі довший інтервал.
Приклад:
| Спроба | Затримка перед спробою | Що сталося |
|---|---|---|
| 1 | 0 с (одразу) | Timeout → помилка |
| 2 | 2 с | 500 Server Error → помилка |
| 3 | 4 с | Timeout → помилка |
| 4 | 8 с | 200 OK → успіх! |
Формула:
const delay = Math.min(
baseDelay * Math.pow(2, retryCount),
maxDelay
);
baseDelay— початкова затримка (наприклад, 2 секунди).retryCount— номер спроби (0, 1, 2, 3, …).maxDelay— максимальна затримка (наприклад, 60 секунд), щоб не чекати годинами.
Реалізація в sync-worker:
async function retrySyncItem(item: OutboxItem) {
const baseDelay = 2000; // 2 секунди
const maxDelay = 60000; // 60 секунд
const delay = Math.min(
baseDelay * Math.pow(2, item.retryCount),
maxDelay
);
await new Promise(resolve => setTimeout(resolve, delay));
try {
const response = await fetch(`/api/${item.resource}`, {
method: item.operation === 'CREATE' ? 'POST' : 'PATCH',
body: JSON.stringify(item.payload),
});
if (response.ok) {
// Успіх — видалити з outbox
await db.outbox.delete(item.id);
await db.trips.update(item.payload.id, { syncStatus: 'synced' });
} else {
// Помилка — збільшити retryCount
await db.outbox.update(item.id, {
retryCount: item.retryCount + 1,
lastError: await response.text(),
});
}
} catch (error) {
// Мережева помилка — збільшити retryCount
await db.outbox.update(item.id, {
retryCount: item.retryCount + 1,
lastError: error.message,
});
}
}
Навіщо це потрібно:
- Зменшує навантаження на сервер: якщо сервер недоступний, тисячі пристроїв не бомбардують його запитами кожної секунди.
- Економить батарею: менше мережевих запитів — менше радіо-активності.
- Збільшує шанс успіху: якщо проблема тимчасова (перезапуск сервера, короткочасна втрата мережі), retry через кілька секунд/хвилин часто допомагає.
Додаткова техніка: jitter (дрижання):
Якщо всі пристрої одночасно отримали помилку (наприклад, сервер упав на 10 секунд), вони синхронно спробують retry через 2 с, 4 с, 8 с — і знову створять пік навантаження. Щоб цього уникнути, додають випадковий зсув (jitter):
const jitter = Math.random() * 1000; // 0–1000 мс
const delay = Math.min(
baseDelay * Math.pow(2, retryCount) + jitter,
maxDelay
);
Тепер кожен пристрій чекає трохи по-різному — навантаження розподіляється в часі.
1.9. Batch Sync — об'єднання операцій
Якщо користувач офлайн створив 10 поїздок і 20 місць, то в outbox буде 30 записів. Відправляти їх по одному — 30 окремих HTTP-запитів — повільно і витратно для батареї. Замість цього можна об'єднати кілька операцій в один batch-запит.
Приклад batch API:
POST /api/sync/batch
Content-Type: application/json
{
"operations": [
{ "op": "CREATE", "resource": "trip", "localId": "local-1", "data": { "title": "Київ" } },
{ "op": "CREATE", "resource": "trip", "localId": "local-2", "data": { "title": "Львів" } },
{ "op": "UPDATE", "resource": "place", "serverId": "place-42", "data": { "name": "Майдан" } }
]
}
Відповідь сервера:
{
"results": [
{ "localId": "local-1", "status": "success", "serverId": "srv-101" },
{ "localId": "local-2", "status": "success", "serverId": "srv-102" },
{ "serverId": "place-42", "status": "success" }
]
}
Переваги batch sync:
- Швидше: один TCP-handshake замість 30.
- Менше батареї: радіо-модуль активний коротший час.
- Атомарність: сервер може обробити всі операції в одній транзакції (або відкотити всі, якщо щось не так).
Коли використовувати:
- Багато дрібних змін: користувач швидко створив 5 поїздок — краще відправити batch.
- Періодична синхронізація: раз на хвилину збирати всі pending-операції і відправляти одним пакетом.
Коли не використовувати:
- Критично важливі дані: якщо треба одразу отримати
serverId(наприклад, для наступного запиту), краще відправити окремо. - Немає batch API: не всі сервери підтримують
/sync/batch— тоді доводиться по одному.
У Nomad ми для простоти відправляємо операції по одній, але в реальному застосунку часто додають batch для оптимізації.
/sync/batch).1.10. Порівняння підходів до синхронізації
Є кілька стратегій організації offline-sync. Ось найпоширеніші:
| Підхід | Суть | Переваги | Недоліки | Коли використовувати |
|---|---|---|---|---|
| Outbox Pattern | Локальна таблиця операцій; фоновий worker обробляє чергу | Простий, гарантія доставки, працює без мережі | Потребує окрему таблицю; складно з залежними операціями | Більшість мобільних застосунків (TODO, CRM, notes) |
| Event Sourcing | Зберігає всі зміни як події (event log); сервер replay подій | Повна історія, легко відкотити | Складний, багато даних, потребує спеціальної архітектури сервера | Фінтех, аудит-лог, collaborative editing |
| CRDT (Conflict-Free Replicated Data Types) | Автоматичне об'єднання змін без конфліктів | Немає ручного merge, працює P2P | Складний, не всі типи даних підтримує | Collaborative editing (Figma, Google Docs), chat |
| Polling (pull from server) | Періодично завантажує всі дані з сервера, замінює локальні | Простий, не потребує outbox | Витратний (багато трафіку), затирає локальні pending-зміни | Readonly-застосунки (новини, каталог) |
| Firebase/Firestore Sync | Хмарна база з вбудованою синхронізацією | Дуже простий (все «з коробки»), realtime | Vendor lock-in, дорого при масштабі, обмежений query | MVP, малі проєкти, realtime-чати |
Для навчання ми використовуємо Outbox Pattern — він найпоширеніший і добре пояснює базові принципи (черга, retry, syncStatus, conflicts). Інші підходи — це еволюція або спеціалізація цієї базової ідеї.
1.11. Коли offline-first не потрібен
Не кожен застосунок має бути offline-first. Іноді простіше зробити online-only (блокувати UI без мережі) або readonly offline (показувати кешовані дані, але не дозволяти створювати нові).
Не варто робити offline-first, якщо:
| Випадок | Чому online-only достатньо |
|---|---|
| Критичні фінансові операції | Потрібна миттєва перевірка балансу, fraud-detection — локальна черга ризикована. |
| Realtime collaboration | Якщо 10 людей одночасно редагують документ, offline-зміни створюють складні конфлікти. |
| Дані з коротким TTL | Курси валют, ціни акцій — через 5 хвилин дані застарілі, синхронізувати немає сенсу. |
| Readonly-контент | Новини, каталог товарів — можна кешувати для читання, але створювати нічого не треба. |
| Прості форми з рідким використанням | Якщо застосунок відкривають раз на місяць з Wi-Fi — складність offline-sync не окупається. |
Для Nomad offline-first доречний, бо:
- Подорожі часто трапляються в місцях без мережі (літак, гори, закордон без роумінгу).
- Створення поїздки/місця — проста операція, конфлікти рідкі.
- Користувач очікує, що щоденник «просто працює», як Notes на iPhone.
Частина 2. Міні-проєкт «Офлайн-чеклист»
Тепер застосуємо теорію на практиці. Створимо простий TODO-застосунок із синхронізацією:
- Користувач додає завдання (онлайн або офлайн).
- Якщо офлайн — завдання потрапляє в локальну чергу з
syncStatus: 'pending'. - Фоновий worker періодично перевіряє мережу і відправляє pending-завдання на mock API.
- У списку біля кожного завдання — badge зі статусом синхронізації.
- При помилці — кнопка «Повторити».
Стек:
- React Native (Expo)
- TypeScript
- Zustand (для простоти, замість RTK)
- MMKV (локальне сховище)
- Mock API (JSON Placeholder або локальний express-сервер; для демо можна просто
setTimeoutзамістьfetch)
Структура проєкту:
Покрокова інструкція
Крок 1. Створити проєкт
npx create-expo-app@latest offline-checklist --template blank-typescript
cd offline-checklist
Крок 2. Встановити залежності
npx expo install expo-router react-native-mmkv @react-native-community/netinfo
npm install zustand
Крок 3. Налаштувати Expo Router
У app.json додати:
{
"expo": {
"scheme": "offline-checklist"
}
}
У package.json змінити main:
{
"main": "expo-router/entry"
}
Крок 4. Створити структуру файлів
Створити папки app/, store/, hooks/ і файли як у ::code-tree вище.
Крок 5. Запустити застосунок
npx expo start
Натисніть i (iOS) або a (Android).
Крок 6. Перевірити офлайн-режим
- Додати кілька завдань з увімкненою мережею → бачите badge «✅ Синхронізовано».
- Вимкнути Wi-Fi на пристрої.
- Додати нове завдання → badge «⏳ Не синхронізовано».
- Увімкнути Wi-Fi → через 5 секунд badge змінюється на «✅ Синхронізовано».
Крок 7. Перевірити retry
Оскільки mock API має 20% шанс помилки, іноді завдання отримає badge «❌ Помилка». Натисніть «Повторити» → статус змінюється на «⏳ Не синхронізовано», і worker спробує знову.
Що відбувається під капотом
- Додавання завдання:
addTask(text)створює об'єктTaskзsyncStatus: 'pending', зберігає в MMKV.- UI одразу показує нове завдання в списку.
- Фонова синхронізація:
useSyncWorkerзапускаєsetIntervalкожні 5 секунд.- Фільтрує
tasks.filter(t => t.syncStatus === 'pending'). - Для кожного pending-завдання викликає
mockApiCall(імітація fetch).
- Успіх:
updateTaskStatus(id, 'synced', serverId)оновлює задачу в сховищі.- Badge змінюється на «✅ Синхронізовано».
- Помилка:
updateTaskStatus(id, 'error', undefined, errorMessage).- Badge «❌ Помилка» + кнопка «Повторити».
- Retry:
- Користувач натискає «Повторити» →
retrySync(id)скидає статус на'pending',retryCount: 0. - Worker знову обробить цю задачу при наступному циклі.
- Користувач натискає «Повторити» →
mockApiCall буде fetch('/api/tasks'), а замість MMKV — SQLite з outbox-таблицею.Критерій «Готово»
- Застосунок запускається на iOS або Android.
- Додавання завдань працює онлайн і офлайн.
- Офлайн-banner з'являється, коли немає мережі.
- Badge синхронізації показує актуальний статус.
- При помилці можна натиснути «Повторити».
- Після перезапуску застосунку завдання лишаються (MMKV persistence).
Частина 3. Наскрізний проєкт «Nomad»
Навіщо користувачу
Ви плануєте відпустку: відкриваєте Nomad у літаку (режим польоту, мережі немає) і створюєте нову поїздку «Париж 2026». Додаєте кілька місць, які хочете відвідати: «Ейфелева вежа», «Лувр», «Монмартр». Застосунок працює миттєво — ви бачите всі зміни одразу, хоч інтернету й немає.
Коли літак приземляється і телефон підключається до мережі, Nomad автоматично синхронізує всі ваші поїздки та місця з сервером. Ви навіть не помічаєте цього процесу — просто іноді бачите маленьку іконку «хмаринка» біля картки, яка через кілька секунд зникає.
Якщо щось пішло не так (сервер недоступний, конфлікт версій), застасунок покаже зрозуміле повідомлення і дасть можливість повторити синхронізацію одним дотиком.
У цій статті ми додамо в Nomad:
- Поле
syncStatusдля кожної поїздки та місця. - Таблицю
outboxу SQLite для черги операцій. - Фоновий
useSyncWorker, який автоматично відправляє зміни на API. - Offline-banner у header списку поїздок.
- Sync-badge на кожній картці поїздки.
- Кнопку «Повторити» для записів із помилкою.
Нитка проєкту
Уже є (з попередніх статей)
- Теми (світла/темна) з чіпами перемикача (стаття 08).
- Список поїздок з mock-даними, FlatList, sticky CTA «Нова поїздка» (статті 06–09).
- Форма створення поїздки з валідацією через React Hook Form + Zod (стаття 10).
- Навігація через Expo Router: tabs (Поїздки / Профіль), stack деталей поїздки, модальне вікно створення (статті 11–12).
- Мережеві запити через RTK Query:
tripsApi.useGetTripsQuery(),useCreateTripMutation()(статті 14–16). - Локальна база SQLite для offline-зберігання поїздок (стаття 19).
Додаємо в цій статті
syncStatusіsyncErrorу типіTripіPlace.- Таблиця
outboxу SQLite з полямиid,operation,resource,payload,createdAt,retryCount,lastError. useSyncWorkerhook — фоновий процес, який:- Слухає зміни мережі через NetInfo.
- Кожні 10 секунд перевіряє outbox.
- Для кожної pending-операції робить POST/PATCH на API.
- Оновлює
syncStatusу локальній базі.
- Offline-banner у
app/(tabs)/index.tsx(список поїздок). - Sync-badge на картці поїздки (
TripCard.tsx). - Retry-кнопка в деталях поїздки для записів із
syncStatus: 'error'.
Повний знімок проєкту
Нижче — вся структура Nomad на момент цієї статті з повним кодом у кожному файлі.
Локальне сховище — AsyncStorage, MMKV, SQLite
Як зберігати дані на пристрої між перезапусками застосунку, коли використовувати key-value сховища та структуровані бази даних, і що вибрати для різних сценаріїв
Камера, медіа та файлова система
Дозволи (permissions), вибір зображень з бібліотеки або камери, стиснення фото та робота з локальними URI у React Native / Expo