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
fetchStatus: 'paused'dengandataberisi: #13 "Disetrika" dari cache, plus jam daridataUpdatedAt.isPendingdenganfetchStatus: 'paused': belum pernah ada data dan gak ada sinyal. Tampilkan pesan, bukan spinner selamanya.networkMode: 'always'menjalankan request walau offline (hasilnya pasti gagal).'offlineFirst'mencoba sekali dulu, berguna di web dengan service worker.
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'))} />
kirimJemputsama sepertiambilPesananSayadi pelajaran API, tapiPOSTdengan body{"client_id":"…","jam":"16:00"}dan melemparGagalKirimkalau!res.ok.- Saat sinyal kembali,
onlineManagermembangunkan query client, yang menjalankan mutation yang tertahan. Saat aplikasi dibuka ulang,resumePausedMutations()dionSuccessyang menjalankannya. retry: jaringan putus atau server 5xx dicoba lagi sampai 5 kali, dengan jeda 1, 2, 4, 8, lalu 16 detik (Math.min(1000 * 2 ** n, 30000)). Kalau HP offline di tengah jeda, percobaannya ditahan sampai online. Jawaban 4xx (misalnya 422 jam di luar jam buka) gak akan berhasil walau diulang, jadi langsung gagal.useMutationState({ filters: { mutationKey: ['jemput'], status: 'pending' } })memberi daftar antrean untuk ditampilkan: "Jemput 16.00: menunggu sinyal".
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
- Emulator Android:
adb shell svc wifi disabledanadb shell svc data disable(kembalikan denganenable), atau menu Extended Controls bagian Cellular. - Simulator iOS: memakai jaringan Mac, dan gak punya mode pesawat. Matikan Wi-Fi Mac, atau pakai Network Link Conditioner (dari Additional Tools for Xcode) dengan profil "100% Loss".
- HP asli: mode pesawat, atau di iPhone menu Settings > Developer > Network Link Conditioner.
- Urutan uji: buka aplikasi tanpa sinyal (cache tampil), minta jemput (masuk antrean), tutup dan buka lagi aplikasinya (antrean masih ada), nyalakan sinyal (terkirim sekali, #13 diperbarui dari server).
⚠️ Jebakan yang sering ketemu
- Cuma memeriksa
isConnected. Di Wi-Fi kafe yang minta login, aplikasi mengira online, request gagal terus, dan setelah 5 kali coba ulang permintaannya dibuang. Pakai jugaisInternetReachable !== false. - Membuat client id di dalam
mutationFn. Tiap percobaan dapat id baru, jadi kiriman ulang tercatat sebagai jemputan baru. Buat id saat tombol diketuk dan simpan divariables. - Lupa
setMutationDefaultsdanresumePausedMutations. Antrean tersimpan, tapi setelah aplikasi dibuka ulang gak ada fungsi untuk mengirimnya. - Mengulang jawaban 4xx.
422tetap422walau dikirim seratus kali. Ulangi cuma kegagalan jaringan dan 5xx. - Layar error saat offline padahal cache ada. Pakai
fetchStatus === 'paused'dan tampilkan data dari HP dengan jam terakhir diperbarui.
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.