Version2

Офлайн-First і синхронізація

Як зберігати зміни без мережі, синхронізувати їх з сервером і показувати статус sync користувачу

Офлайн-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.

Офлайн-first — це архітектура, де застосунок завжди працює з локальною базою, а синхронізація з сервером відбувається у фоні. Користувач не блокується на час запиту — він бачить свої дані одразу, навіть якщо мережі немає.

Частина 1. Теорія офлайн-синхронізації

1.1. Навіщо офлайн-first у мобільних застосунках

У веб-застосунках зазвичай діє модель online-first: користувач натискає «Зберегти», браузер робить POST-запит на сервер, і лише після відповіді 200 OK показується успіх. Якщо мережі немає або сервер недоступний — з'являється помилка, і користувач чекає.

У мобільних застосунках цей підхід працює гірше через кілька причин:

ПричинаЧому це важливо на телефоні
Нестабільна мережаКористувач переміщується між Wi-Fi, LTE, метро, ліфтом — з'єднання може зникнути в будь-який момент.
Повільний інтернетУ роумінгу або на околиці міста запит може йти кілька секунд — чекати на відповідь сервера дратує.
БатареяСинхронні запити тримають екран активним; фонова черга дозволяє об'єднати кілька операцій в один batch.
UX-очікуванняКористувачі звикли, що телефон «просто працює» — як у native-застосунках (Notes, Photos), які зберігають локально і синхронізують сховано.

Офлайн-first вирішує ці проблеми:

  1. Зміни зберігаються локально (MMKV, SQLite, Realm) одразу — без чекання на сервер.
  2. UI оновлюється миттєво — користувач бачить свою нову поїздку в списку через мілісекунди.
  3. Фонова черга (outbox) автоматично відправляє зміни на сервер, коли з'являється мережа.
  4. Статус синхронізації показує, чи дані вже на сервері, чи ще в черзі.
Аналогія: уявіть папку «Вихідні» в email-клієнті. Ви пишете листа без інтернету, натискаєте «Надіслати» — і лист одразу потрапляє в папку «Вихідні». Коли з'являється мережа, клієнт сам відправляє всі листи з черги. Офлайн-first працює так само: ваші зміни — це «листи», а 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:

  1. Користувач створює поїздку → застосунок одразу записує її в локальну базу SQLite з syncStatus: 'pending'.
  2. Одночасно додається запис у таблицю outbox: { operation: 'CREATE', resource: 'trip', payload: { title, startDate, ... } }.
  3. Фоновий процес (useEffect, setInterval, або нативний WorkManager/BackgroundTasks) періодично перевіряє outbox.
  4. Якщо є мережа — бере перший запис з черги, робить POST/PATCH/DELETE на API.
  5. Якщо сервер відповідає 200 OK — видаляє запис з outbox, оновлює syncStatus: 'synced' у локальній базі, записує serverId (якщо сервер повернув ID).
  6. Якщо помилка (timeout, 500, 409 conflict) — збільшує retryCount, записує lastError, залишає запис у черзі для наступної спроби.
Outbox — це локальна черга операцій, які застосунок має відправити на сервер. Кожен запис у черзі описує одну зміну (створити, оновити, видалити) і зберігає payload для запиту.

Переваги outbox:

  • Гарантія доставки: навіть якщо застосунок закриють або телефон вимкнеться, черга лишається в локальній базі — при наступному запуску синхронізація продовжиться.
  • Порядок операцій: outbox обробляє записи за часом створення (createdAt) — спочатку старі, потім нові. Це важливо, якщо одна операція залежить від іншої (наприклад, спочатку створити поїздку, потім додати до неї місце).
  • Retry-логіка: якщо запит не вдався, запис лишається в черзі, і застосунок спробує знову через кілька секунд або хвилин (exponential backoff).

1.3. syncStatus — стани синхронізації

Кожен запис у локальній базі (поїздка, місце, фото) має поле syncStatus, яке показує, чи дані вже на сервері:

type SyncStatus = 
  | 'pending'   // локально створено, але ще не відправлено
  | 'syncing'   // зараз відправляється на сервер
  | 'synced'    // успішно синхронізовано, є serverId
  | 'error';    // помилка синхронізації (conflict, server error)

Життєвий цикл запису:

  1. Створення офлайн: користувач додає поїздку без мережі → syncStatus: 'pending', serverId: null.
  2. Початок синхронізації: outbox-worker бере запис з черги → syncStatus: 'syncing'.
  3. Успіх: сервер повертає 201 Created з { id: "srv-123" }syncStatus: 'synced', serverId: "srv-123", запис видаляється з outbox.
  4. Помилка: сервер повертає 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) і стратегії розв'язання

Конфлікт виникає, коли:

  1. Користувач змінює поїздку офлайн (локально version: 3, syncStatus: 'pending').
  2. Інший користувач (або той самий на іншому пристрої) змінює ту саму поїздку онлайн — сервер має version: 4.
  3. Коли перший користувач синхронізується, сервер повертає 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.

Конфлікт синхронізації — це ситуація, коли локальна і серверна версії запису різняться, і треба вирішити, яку залишити. Найпростіша стратегія — Last Write Wins: порівняти timestamp і взяти новішу версію.

Приклад конфлікту в Nomad:

  1. Користувач офлайн редагує поїздку "Київ""Київ + Львів", updatedAt: 2026-08-11T10:00:00Z.
  2. Інший пристрій онлайн редагує ту саму поїздку → "Київ + Одеса", updatedAt: 2026-08-11T10:05:00Z (на 5 хвилин пізніше).
  3. Перший пристрій синхронізується, сервер повертає 409 + серверну версію.
  4. Застосунок порівнює: серверна 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:

  1. Не блокувати UI: користувач може продовжувати працювати офлайн, навіть якщо частина даних не синхронізована.
  2. Показувати прогрес: якщо в outbox 10 записів, показати «Синхронізація 3/10» або прогрес-бар.
  3. Чітка помилка: якщо sync fail — не просто «Помилка», а «Сервер недоступний» або «Конфлікт: ця поїздка вже існує».
  4. Retry без зусиль: кнопка «Повторити» або автоматичний retry через 30 с / 1 хв / 5 хв (exponential backoff).
  5. Оптимістичні оновлення: показуємо зміни одразу (optimistic UI), а якщо сервер відхилить — відкочуємо з toast «Зміну не збережено».
Оптимістичне оновлення (optimistic UI) — це коли застосунок показує зміну в інтерфейсі одразу, до відповіді сервера. Якщо запит успішний — нічого не змінюється. Якщо помилка — UI повертається до попереднього стану з повідомленням про помилку. Це робить застосунок швидшим на відчуття.

1.6. Архітектура offline-first у React Native з RTK

Тепер з'єднаємо всі частини в одну систему. У застосунку на RTK + RTK Query + SQLite архітектура виглядає так:

Loading diagram...
@startuml
skinparam backgroundColor #ffffff
skinparam componentStyle rectangle

package "React Components" {
  [TripsList]
  [TripForm]
}

package "RTK Store" {
  [tripsSlice]
  [outboxSlice]
  [tripsApi (RTK Query)]
}

package "Local DB (SQLite)" {
  database "trips table" as TripsDB
  database "outbox table" as OutboxDB
}

package "Sync Engine" {
  [useSyncWorker hook]
}

cloud "Backend API" as API

[TripForm] --> [tripsSlice] : dispatch createTrip
[tripsSlice] --> TripsDB : write trip (syncStatus: pending)
[tripsSlice] --> OutboxDB : add outbox item (CREATE)
[TripsList] <-- TripsDB : read trips

[useSyncWorker hook] --> OutboxDB : poll every 10s
[useSyncWorker hook] --> API : POST /trips
API --> [useSyncWorker hook] : 201 + serverId
[useSyncWorker hook] --> TripsDB : update syncStatus: synced
[useSyncWorker hook] --> OutboxDB : delete outbox item

[tripsApi (RTK Query)] --> API : fetch trips (online)
[tripsApi (RTK Query)] --> TripsDB : cache

@enduml

Потік даних:

  1. Створення запису (офлайн):
    • Користувач натискає «Створити поїздку» → dispatch(createTrip({ title, startDate })).
    • tripsSlice генерує локальний UUID, записує поїздку в SQLite з syncStatus: 'pending'.
    • Одночасно додає запис у outbox таблицю: { operation: 'CREATE', resource: 'trip', payload: { title, startDate } }.
    • UI одразу показує нову поїздку в списку з badge «Не синхронізовано».
  2. Синхронізація (фон):
    • Хук 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.
  3. Завантаження з сервера (онлайн):
    • При запуску застосунку (або pull-to-refresh) tripsApi.useGetTripsQuery() завантажує список із сервера.
    • RTK Query кешує результат, але не затирає локальні pending-записи — вони існують лише локально, поки не синхронізуються.
    • Merge-логіка: якщо є запис з serverId у локальній базі і той самий id на сервері — оновлюємо локальну версію серверною (якщо updatedAt серверної новіше).
Offline-first у RTK — це поєднання локальної бази (джерело істини для UI), outbox-черги (операції на відправку) і фонового sync-worker (обробка черги). RTK Query використовується для завантаження даних з сервера, але не замінює локальну базу — вона лише доповнює її.

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>
  );
}
NetInfo на емуляторі: емулятор зазвичай завжди показує 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 (експоненційна затримка) — кожна наступна спроба відбувається через удвічі довший інтервал.

Приклад:

СпробаЗатримка перед спробоюЩо сталося
10 с (одразу)Timeout → помилка
22 с500 Server Error → помилка
34 сTimeout → помилка
48 с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 через кілька секунд/хвилин часто допомагає.
Exponential backoff — це стратегія повторних спроб, де затримка між спробами зростає експоненційно (2 с, 4 с, 8 с, 16 с, …). Це зменшує навантаження на сервер і батарею, даючи час системі відновитися.

Додаткова техніка: 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 для оптимізації.

Batch sync — це відправка кількох операцій (CREATE, UPDATE, DELETE) в одному HTTP-запиті. Це швидше і економніше, але вимагає підтримки на сервері (endpoint /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Хмарна база з вбудованою синхронізацієюДуже простий (все «з коробки»), realtimeVendor lock-in, дорого при масштабі, обмежений queryMVP, малі проєкти, realtime-чати

Для навчання ми використовуємо Outbox Pattern — він найпоширеніший і добре пояснює базові принципи (черга, retry, syncStatus, conflicts). Інші підходи — це еволюція або спеціалізація цієї базової ідеї.

Outbox Pattern — найпростіший і найпоширеніший підхід для offline-first у мобільних застасунках. Він дає повний контроль над синхронізацією, не вимагає спеціальних серверних фреймворків і працює з будь-яким REST API.

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. Перевірити офлайн-режим

  1. Додати кілька завдань з увімкненою мережею → бачите badge «✅ Синхронізовано».
  2. Вимкнути Wi-Fi на пристрої.
  3. Додати нове завдання → badge «⏳ Не синхронізовано».
  4. Увімкнути Wi-Fi → через 5 секунд badge змінюється на «✅ Синхронізовано».

Крок 7. Перевірити retry

Оскільки mock API має 20% шанс помилки, іноді завдання отримає badge «❌ Помилка». Натисніть «Повторити» → статус змінюється на «⏳ Не синхронізовано», і worker спробує знову.


Що відбувається під капотом

  1. Додавання завдання:
    • addTask(text) створює об'єкт Task з syncStatus: 'pending', зберігає в MMKV.
    • UI одразу показує нове завдання в списку.
  2. Фонова синхронізація:
    • useSyncWorker запускає setInterval кожні 5 секунд.
    • Фільтрує tasks.filter(t => t.syncStatus === 'pending').
    • Для кожного pending-завдання викликає mockApiCall (імітація fetch).
  3. Успіх:
    • updateTaskStatus(id, 'synced', serverId) оновлює задачу в сховищі.
    • Badge змінюється на «✅ Синхронізовано».
  4. Помилка:
    • updateTaskStatus(id, 'error', undefined, errorMessage).
    • Badge «❌ Помилка» + кнопка «Повторити».
  5. Retry:
    • Користувач натискає «Повторити» → retrySync(id) скидає статус на 'pending', retryCount: 0.
    • Worker знову обробить цю задачу при наступному циклі.
Цей міні-проєкт показує весь життєвий цикл offline-sync: створення → pending → syncing → synced/error → retry. У реальному застосунку замість 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.
  • useSyncWorker hook — фоновий процес, який:
    • Слухає зміни мережі через NetInfo.
    • Кожні 10 секунд перевіряє outbox.
    • Для кожної pending-операції робить POST/PATCH на API.
    • Оновлює syncStatus у локальній базі.
  • Offline-banner у app/(tabs)/index.tsx (список поїздок).
  • Sync-badge на картці поїздки (TripCard.tsx).
  • Retry-кнопка в деталях поїздки для записів із syncStatus: 'error'.

Повний знімок проєкту

Нижче — вся структура Nomad на момент цієї статті з повним кодом у кожному файлі.

Copyright © 2026