Offline-First Architecture — Mobile

Offline-First di Mobile Mobile user sering kehilangan koneksi — di lift, tunnel, area remote. Offline-first berarti app tetap fungsional tanpa internet, dan…

Offline-first artinya aplikasi membaca dan menulis ke HP dulu, lalu menyamakan datanya dengan server begitu sinyal ada. Offline bukan keadaan error, tapi keadaan biasa. Di toko Bu Rini, sinyal di dekat meja kasir sering hilang. Bayangin buku titipan di meja kasir: saat telepon ke kurir putus, kasir menulis "jemput Mbak Wulan jam 16.00, nomor J-7f3a", lalu menelepon lagi begitu sambungan kembali. Karena tiap catatan punya nomor, kurir gak datang dua kali walau kasir sempat menelepon dua kali. Di aplikasi, buku itu namanya outbox, dan nomornya client id.

🧪 Tebakan cepat: Mbak Wulan mengetuk "Minta jemput" tanpa sinyal, dan permintaannya masuk antrean. Sinyal kembali sebentar: request-nya sampai di server, tapi balasannya hilang di jalan, jadi aplikasi mengirim ulang. Berapa permintaan jemput yang dilihat kurir? Jawabannya ada di bagian "Satu permintaan, satu jemputan: client id".

Data apa yang tinggal di HP

Data Disimpan di Kalau beda dengan server
Order #13 milik Mbak Wulan cache TanStack Query di REACT_QUERY_OFFLINE_CACHE (pelajaran penyimpanan lokal) server menang
Filter "Disetrika" Zustand persist, kunci filter-pesanan gak ada di server
Permintaan jemput yang belum terkirim outbox: mutation yang tertahan, ikut tersimpan di cache yang sama dikirim ulang sampai server menjawab
Token login expo-secure-store login ulang

Untuk aplikasi pelanggan ini, cache query yang disimpan sudah cukup jadi sumber data di HP: datanya sedikit (order aktif satu akun), HP cuma membacanya, dan status order milik papan Bu Rini. expo-sqlite baru sepadan kalau HP menyimpan ribuan baris yang dicari dan disaring tanpa sinyal, atau mengubah banyak data sendiri. Contohnya aplikasi kasir yang mencatat 300 nota sehari. Di situ SQLite jadi sumber kebenaran di HP, dan kode sinkronisasinya kamu tulis sendiri.

Membaca saat offline

TanStack Query perlu tahu HP sedang online atau gak. Di web dia membaca event browser. Di React Native gak ada, jadi sambungkan @react-native-community/netinfo (versi 12.0.1, yang dipasang npx expo install di SDK 57) ke onlineManager:

// src/lib/online.ts (di-import sekali di src/app/_layout.tsx)
import NetInfo from '@react-native-community/netinfo';
import { onlineManager } from '@tanstack/react-query';

onlineManager.setEventListener((setOnline) =>
  NetInfo.addEventListener((state) => {
    setOnline(state.isConnected === true && state.isInternetReachable !== false);
  }),
);

Bawaannya, query memakai networkMode: 'online': saat offline, query gak dijalankan tapi ditahan (fetchStatus: 'paused'), dan data dari cache tetap tampil. Begitu online, query mengambil ulang sendiri.

const { data, isPending, fetchStatus, dataUpdatedAt } = useQuery({ queryKey: ['orders', 'saya'], queryFn: ambilPesananSaya });
const offline = fetchStatus === 'paused';
if (isPending) return offline ? <Text>Offline, dan belum ada data di HP.</Text> : <ActivityIndicator />;
// offline dengan cache: tampilkan "Offline. Data dari HP, terakhir diperbarui 14.05." di atas daftar

Apa arti "online"

Keadaan HP isConnected isInternetReachable onlineManager
Mode pesawat false false offline
Wi-Fi kafe yang minta login dulu (captive portal) true false offline
Baru tersambung, belum diperiksa true null online (dicoba)
Sinyal normal true true online

Tersambung ke Wi-Fi belum tentu tersambung ke internet. Di Android, isInternetReachable datang dari sistem (jaringan yang sudah "validated"). Di iOS, NetInfo memeriksanya sendiri: request HEAD ke https://clients3.google.com/generate_204, dianggap internet kalau jawabannya 204, diulang tiap 60 detik saat online dan tiap 5 detik saat belum. Halaman login Wi-Fi kafe menjawab dengan halamannya sendiri, bukan 204. Alamat itu bisa diganti ke server sendiri lewat NetInfo.configure({ reachabilityUrl, reachabilityTest }). Tetap saja, kata terakhir ada di request-nya: "online" pun bisa gagal, jadi kegagalan jaringan diperlakukan seperti offline, yaitu dicoba lagi.

Outbox: antrean tulisan

Mutation TanStack Query yang dijalankan saat offline gak gagal, tapi ditahan (isPaused: true). Persister menyimpan mutation yang tertahan bersama cache, jadi antreannya selamat walau aplikasi ditutup. Syaratnya, fungsi kirimnya didaftarkan dengan setMutationDefaults, karena fungsi gak bisa ikut disimpan sebagai JSON:

// src/lib/jemput.ts
export function jemputBaru(jam: string): Jemput {
  return { clientId: Crypto.randomUUID(), jam }; // expo-crypto: id dibuat SEKALI, saat tombol diketuk
}

export function pasangJemput(queryClient: QueryClient) {
  queryClient.setMutationDefaults(['jemput'], {
    mutationFn: kirimJemput, // POST /api/me/pickups dengan { client_id, jam }
    scope: { id: 'jemput' }, // dikirim satu per satu, sesuai urutan
    retry: (gagal, error) => !(error instanceof GagalKirim && error.status < 500) && gagal < 5,
  });
}

// src/app/_layout.tsx
<PersistQueryClientProvider client={queryClient} persistOptions={{ persister, maxAge: 1000 * 60 * 60 * 24 }}
  onSuccess={() => queryClient.resumePausedMutations()}>

// tombolnya
const jemput = useMutation<{ id: number }, Error, Jemput>({ mutationKey: ['jemput'] });
<Button title="Minta jemput jam 16.00" onPress={() => jemput.mutate(jemputBaru('16:00'))} />

Satu permintaan, satu jemputan: client id

Request bisa sampai di server sementara balasannya hilang: aplikasi menunggu 10 detik (AbortSignal.timeout), menganggapnya gagal, lalu mengirim ulang. Tanpa penanda, server mencatat dua jemputan. Dengan client_id, server mengenali kiriman ulang:

// routes/api.php, di grup auth:sanctum (kolom client_id: $table->uuid('client_id')->unique())
Route::post('/me/pickups', function (Request $request) {
    $data = $request->validate(['client_id' => ['required', 'uuid'], 'jam' => ['required', 'date_format:H:i']]);
    $pickup = $request->user()->pickups()->firstOrCreate(['client_id' => $data['client_id']], ['jam' => $data['jam']]);

    return response()->json(['data' => ['id' => $pickup->id]], $pickup->wasRecentlyCreated ? 201 : 200);
});
Kiriman Server Aplikasi
1: client_id 7f3a… baris baru, 201, balasan hilang timeout, coba lagi 1 detik kemudian
2: client_id 7f3a… sama baris sudah ada, 200 dengan id yang sama terkirim

Jadi kurir melihat satu permintaan. Itu jawaban tebakannya. Kunci seperti ini disebut idempotency key: mengirim dua kali hasilnya sama dengan sekali. Kalau dua kiriman datang bersamaan, firstOrCreate di Laravel menangkap pelanggaran indeks unik dan mengambil baris yang sudah ada.

Konflik: siapa yang menang

Konflik terjadi saat data di HP dan di server berubah sendiri-sendiri. Strategi yang umum:

Strategi Cara kerja Cocok untuk
Server menang data server menimpa HP status order #13 (milik papan Bu Rini)
Klien menang data HP menimpa server draf pribadi, pengaturan
Last-write-wins timestamp terbaru menang, yang lain hilang data sederhana yang jarang diubah dua orang
Merge digabung per kolom catatan yang diubah di kolom berbeda
Manual pengguna memilih dokumen penting
Nomor versi server menolak kiriman dari versi lama, klien mengambil ulang lalu mencoba lagi data yang diubah banyak orang

Di aplikasi ini, HP gak pernah mengubah status order. Saat offline, #13 tampil "Disetrika" dari cache, sementara Bu Rini sudah memindahkannya ke "Siap diambil". Begitu online, query mengambil ulang dan server menang. Permintaan jemput cuma ditambah, gak diubah, jadi masalahnya bukan konflik tapi duplikat, yang diselesaikan client_id.

Menguji tanpa sinyal

⚠️ Jebakan yang sering ketemu

TL;DR: Offline-first berarti data disimpan dan dibaca di HP dulu, lalu disinkronkan ke server saat ada koneksi. Untuk aplikasi pelanggan Laundry Bu Rini, cache TanStack Query yang disimpan sudah cukup sebagai sumber data di HP (expo-sqlite untuk ribuan baris atau banyak perubahan offline). Sambungkan NetInfo 12.0.1 ke onlineManager dengan isConnected === true && isInternetReachable !== false, karena Wi-Fi dengan halaman login tersambung tapi gak punya internet. Query yang offline ditahan (fetchStatus: 'paused') dan menampilkan cache. Tulisan masuk outbox: mutation yang tertahan, disimpan persister, didaftarkan dengan setMutationDefaults, dijalankan lagi saat online atau lewat resumePausedMutations(), dicoba ulang dengan jeda 1, 2, 4, 8, 16 detik. Tiap permintaan membawa client_id (idempotency key) yang dibuat saat tombol diketuk, jadi server yang menerima kiriman ulang menjawab 200 dengan baris yang sama. Konflik diselesaikan dengan strategi seperti server menang (status order), klien menang, last-write-wins dengan timestamp, merge, manual, atau nomor versi. Uji dengan adb shell svc, Network Link Conditioner, dan mode pesawat.

Yang akan kamu pelajari