Building offline-first React Native apps
Mobile users lose connectivity constantly — elevators, tunnels, bad signal areas. If your app shows an error spinner and stops working when the network disappears, you are making users choose between using your app and having WiFi. Offline-first means the app works first, syncs when it can. Here is the architecture I use to build it.
The offline-first mental model
The key shift: treat the local database as the source of truth for the UI, not the server. The UI reads from local storage. A sync layer keeps local storage in sync with the server. The network becomes a background concern.
Three moving parts:
- Local store: SQLite or AsyncStorage for persisting data across sessions
- Sync queue: A queue of mutations made while offline, replayed when reconnected
- Connectivity monitor: Triggers sync when the connection comes back
Setting up the local database with MMKV and Zustand
MMKV is 10x faster than AsyncStorage and synchronous. I use it with Zustand for a local-first state layer:
import { MMKV } from 'react-native-mmkv';
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
const storage = new MMKV();
const zustandStorage = {
getItem: (key: string) => storage.getString(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
};
interface Task {
id: string;
title: string;
completed: boolean;
updatedAt: number;
}
interface TaskStore {
tasks: Record<string, Task>;
addTask: (task: Task) => void;
updateTask: (id: string, updates: Partial<Task>) => void;
deleteTask: (id: string) => void;
}
export const useTaskStore = create<TaskStore>()(
persist(
(set) => ({
tasks: {},
addTask: (task) => set((state) => ({
tasks: { ...state.tasks, [task.id]: task },
})),
updateTask: (id, updates) => set((state) => ({
tasks: {
...state.tasks,
[id]: { ...state.tasks[id], ...updates, updatedAt: Date.now() },
},
})),
deleteTask: (id) => set((state) => {
const { [id]: _, ...rest } = state.tasks;
return { tasks: rest };
}),
}),
{
name: 'task-store',
storage: createJSONStorage(() => zustandStorage),
}
)
);
The sync queue
Every mutation goes into the sync queue. The queue replays against the server when connectivity returns:
type SyncOperation =
| { type: 'CREATE_TASK'; payload: Task }
| { type: 'UPDATE_TASK'; payload: { id: string; updates: Partial<Task> } }
| { type: 'DELETE_TASK'; payload: { id: string } };
interface SyncStore {
queue: SyncOperation[];
isSyncing: boolean;
enqueue: (op: SyncOperation) => void;
dequeue: (count: number) => void;
setIsSyncing: (val: boolean) => void;
}
export const useSyncStore = create<SyncStore>()(
persist(
(set) => ({
queue: [],
isSyncing: false,
enqueue: (op) => set((state) => ({ queue: [...state.queue, op] })),
dequeue: (count) => set((state) => ({ queue: state.queue.slice(count) })),
setIsSyncing: (val) => set({ isSyncing: val }),
}),
{ name: 'sync-queue', storage: createJSONStorage(() => zustandStorage) }
)
);
Connectivity detection and auto-sync
import NetInfo from '@react-native-community/netinfo';
import { useEffect } from 'react';
import { api } from '../api';
export function useSyncOnReconnect() {
const { queue, dequeue, setIsSyncing } = useSyncStore();
useEffect(() => {
const unsubscribe = NetInfo.addEventListener(async (state) => {
if (!state.isConnected || !state.isInternetReachable) return;
if (queue.length === 0) return;
setIsSyncing(true);
try {
// Process in batches of 10
const batch = queue.slice(0, 10);
await Promise.all(batch.map(processSyncOperation));
dequeue(batch.length);
} catch (error) {
console.error('Sync failed:', error);
// Queue persists — will retry on next reconnect
} finally {
setIsSyncing(false);
}
});
return unsubscribe;
}, [queue.length]);
}
async function processSyncOperation(op: SyncOperation) {
switch (op.type) {
case 'CREATE_TASK':
return api.post('/tasks', op.payload);
case 'UPDATE_TASK':
return api.patch(`/tasks/${op.payload.id}`, op.payload.updates);
case 'DELETE_TASK':
return api.delete(`/tasks/${op.payload.id}`);
}
}
Combining local writes with sync enqueue
// A hook that writes locally AND enqueues for sync
export function useTasks() {
const { tasks, addTask, updateTask, deleteTask } = useTaskStore();
const { enqueue } = useSyncStore();
const createTask = (title: string) => {
const task: Task = {
id: crypto.randomUUID(),
title,
completed: false,
updatedAt: Date.now(),
};
addTask(task); // immediate local write
enqueue({ type: 'CREATE_TASK', payload: task }); // background sync
};
const toggleTask = (id: string) => {
const updates = { completed: !tasks[id].completed };
updateTask(id, updates); // immediate local write
enqueue({ type: 'UPDATE_TASK', payload: { id, updates } }); // sync
};
return {
tasks: Object.values(tasks),
createTask,
toggleTask,
};
}
Conflict resolution
When the user edits a record offline and the server has a newer version, you have a conflict. The simplest resolution: last-write-wins based on updatedAt timestamp. Compare timestamps when applying server data:
async function mergeServerTasks(serverTasks: Task[]) {
const { tasks, addTask, updateTask } = useTaskStore.getState();
for (const serverTask of serverTasks) {
const localTask = tasks[serverTask.id];
if (!localTask || serverTask.updatedAt > localTask.updatedAt) {
// Server is newer — overwrite local
addTask(serverTask);
}
// If local is newer — the sync queue will update the server when online
}
}
Showing sync status to users
Never hide the sync state. Users need to know if their changes are saved:
export function SyncStatusBar() {
const { queue, isSyncing } = useSyncStore();
const isConnected = useNetworkStatus();
if (isSyncing) return <Banner color="blue" text="Syncing..." />;
if (queue.length > 0 && !isConnected) {
return <Banner color="amber" text={`${queue.length} changes waiting to sync`} />;
}
return null;
}
Offline-first adds complexity. But it adds it in the right place — the data layer — and hides it from users. They just see an app that works.