Building offline-first React Native apps
← Back
April 4, 2026React Native8 min read

Building offline-first React Native apps

Published April 4, 20268 min read

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:

  1. Local store: SQLite or AsyncStorage for persisting data across sessions
  2. Sync queue: A queue of mutations made while offline, replayed when reconnected
  3. 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:

typescript
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:

typescript
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

typescript
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

typescript
// 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:

typescript
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:

typescript
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.

Share this
← All Posts8 min read