Back to Posts

Building Offline-First React Native Apps

By Lumina Software
react-nativemobile-developmentexpoarchitecture

Building Offline-First React Native Apps

Users expect apps to work offline. Whether they're on a plane, in a subway tunnel, or just have poor connectivity, your app should provide value. Here's how to build offline-first React Native apps.

Why Offline-First?

User Expectations

  • Always available: App works regardless of connectivity
  • Fast interactions: No waiting for network requests
  • Data persistence: User's work isn't lost
  • Seamless sync: Changes sync when connection returns

Benefits

  • Better UX: Instant responses, no loading spinners
  • Reduced server load: Less API traffic
  • Cost savings: Fewer API calls
  • Reliability: Works in poor network conditions

Architecture Patterns

1. Local-First Architecture

Data lives locally first, syncs to server:

// Data flow: Local → Sync → Server
class OfflineFirstStore {
  private localDB: LocalDatabase;
  private syncQueue: SyncQueue;
  
  async create(item: Item): Promise<Item> {
    // 1. Save locally immediately
    const saved = await this.localDB.save(item);
    
    // 2. Queue for sync
    await this.syncQueue.add({
      type: 'create',
      item: saved,
    });
    
    // 3. Try to sync immediately
    await this.syncQueue.process();
    
    return saved;
  }
}

2. Optimistic Updates

Update UI immediately, sync in background:

function useOptimisticMutation() {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: updateItem,
    onMutate: async (newItem) => {
      // Cancel outgoing refetches
      await queryClient.cancelQueries({ queryKey: ['items'] });
      
      // Snapshot previous value
      const previousItems = queryClient.getQueryData(['items']);
      
      // Optimistically update
      queryClient.setQueryData(['items'], (old) =>
        old.map(item => item.id === newItem.id ? newItem : item)
      );
      
      return { previousItems };
    },
    onError: (err, newItem, context) => {
      // Rollback on error
      queryClient.setQueryData(['items'], context.previousItems);
    },
    onSettled: () => {
      // Sync when online
      queryClient.invalidateQueries({ queryKey: ['items'] });
    },
  });
}

Local Storage Solutions

1. AsyncStorage (Simple Key-Value)

import AsyncStorage from '@react-native-async-storage/async-storage';

class SimpleStorage {
  async save(key: string, value: any): Promise<void> {
    await AsyncStorage.setItem(key, JSON.stringify(value));
  }
  
  async load<T>(key: string): Promise<T | null> {
    const data = await AsyncStorage.getItem(key);
    return data ? JSON.parse(data) : null;
  }
  
  async remove(key: string): Promise<void> {
    await AsyncStorage.removeItem(key);
  }
}

Use for: Simple preferences, small data

2. SQLite (Structured Data)

import * as SQLite from 'expo-sqlite';

class SQLiteStorage {
  private db: SQLite.SQLiteDatabase;
  
  async init(): Promise<void> {
    this.db = await SQLite.openDatabaseAsync('app.db');
    await this.db.execAsync(`
      CREATE TABLE IF NOT EXISTS items (
        id TEXT PRIMARY KEY,
        title TEXT NOT NULL,
        completed INTEGER DEFAULT 0,
        updated_at INTEGER DEFAULT 0
      );
    `);
  }
  
  async save(item: Item): Promise<void> {
    await this.db.runAsync(
      'INSERT OR REPLACE INTO items (id, title, completed, updated_at) VALUES (?, ?, ?, ?)',
      [item.id, item.title, item.completed ? 1 : 0, Date.now()]
    );
  }
  
  async loadAll(): Promise<Item[]> {
    const result = await this.db.getAllAsync('SELECT * FROM items');
    return result.map(row => ({
      id: row.id,
      title: row.title,
      completed: row.completed === 1,
      updatedAt: row.updated_at,
    }));
  }
}

Use for: Complex queries, relationships, large datasets

3. WatermelonDB (Reactive Database)

import { Database } from '@nozbe/watermelondb';
import SQLiteAdapter from '@nozbe/watermelondb/adapters/sqlite';

class WatermelonStorage {
  private database: Database;
  
  constructor() {
    const adapter = new SQLiteAdapter({
      schema: mySchema,
    });
    
    this.database = new Database({
      adapter,
      modelClasses: [Item],
    });
  }
  
  async observeItems(): Promise<Observable<Item[]>> {
    return this.database.collections
      .get('items')
      .query()
      .observe();
  }
}

Use for: Reactive updates, complex relationships

Sync Strategies

1. Polling

Periodically check for updates:

class PollingSync {
  private interval: NodeJS.Timeout;
  
  start(): void {
    this.interval = setInterval(async () => {
      if (await this.isOnline()) {
        await this.sync();
      }
    }, 30000); // Every 30 seconds
  }
  
  stop(): void {
    clearInterval(this.interval);
  }
  
  private async sync(): Promise<void> {
    const localChanges = await this.getLocalChanges();
    const serverChanges = await this.fetchServerChanges();
    
    await this.applyChanges(localChanges, serverChanges);
  }
}

2. Event-Driven Sync

Sync when events occur:

import NetInfo from '@react-native-community/netinfo';

class EventDrivenSync {
  constructor() {
    NetInfo.addEventListener(state => {
      if (state.isConnected) {
        this.sync();
      }
    });
  }
  
  async sync(): Promise<void> {
    // Sync when connection restored
  }
}

3. Background Sync

Use background tasks:

import * as BackgroundFetch from 'expo-background-fetch';
import * as TaskManager from 'expo-task-manager';

const BACKGROUND_SYNC_TASK = 'background-sync';

TaskManager.defineTask(BACKGROUND_SYNC_TASK, async () => {
  await syncData();
  return BackgroundFetch.BackgroundFetchResult.NewData;
});

async function registerBackgroundSync(): Promise<void> {
  await BackgroundFetch.registerTaskAsync(BACKGROUND_SYNC_TASK, {
    minimumInterval: 15 * 60, // 15 minutes
    stopOnTerminate: false,
    startOnBoot: true,
  });
}

Conflict Resolution

Last-Write-Wins

class LastWriteWins {
  async resolveConflict(local: Item, server: Item): Promise<Item> {
    // Use timestamp to determine winner
    return local.updatedAt > server.updatedAt ? local : server;
  }
}

Operational Transformation

class OperationalTransform {
  async resolveConflict(localOps: Operation[], serverOps: Operation[]): Promise<Operation[]> {
    // Transform operations to resolve conflicts
    return this.transform(localOps, serverOps);
  }
}

User Resolution

class UserResolution {
  async resolveConflict(local: Item, server: Item): Promise<Item> {
    // Show conflict UI to user
    const resolved = await this.showConflictDialog(local, server);
    return resolved;
  }
}

Implementation Example

Complete Offline-First Hook

function useOfflineItems() {
  const [items, setItems] = useState<Item[]>([]);
  const [isOnline, setIsOnline] = useState(true);
  const [pendingSync, setPendingSync] = useState<SyncOperation[]>([]);
  
  // Monitor connectivity
  useEffect(() => {
    const unsubscribe = NetInfo.addEventListener(state => {
      setIsOnline(state.isConnected ?? false);
      
      if (state.isConnected) {
        syncPendingChanges();
      }
    });
    
    return unsubscribe;
  }, []);
  
  // Load from local storage
  useEffect(() => {
    loadLocalItems();
  }, []);
  
  // Sync when online
  useEffect(() => {
    if (isOnline && pendingSync.length > 0) {
      syncPendingChanges();
    }
  }, [isOnline, pendingSync]);
  
  async function loadLocalItems(): Promise<void> {
    const local = await storage.loadAll();
    setItems(local);
  }
  
  async function createItem(item: Item): Promise<void> {
    // Save locally
    await storage.save(item);
    setItems(prev => [...prev, item]);
    
    // Queue for sync
    if (isOnline) {
      await syncItem(item);
    } else {
      setPendingSync(prev => [...prev, { type: 'create', item }]);
    }
  }
  
  async function syncPendingChanges(): Promise<void> {
    for (const operation of pendingSync) {
      try {
        await syncOperation(operation);
        setPendingSync(prev => prev.filter(op => op !== operation));
      } catch (error) {
        console.error('Sync failed:', error);
      }
    }
  }
  
  return {
    items,
    isOnline,
    pendingSync: pendingSync.length,
    createItem,
    updateItem,
    deleteItem,
  };
}

UI Indicators

Show Sync Status

function SyncIndicator({ isOnline, pendingSync }: SyncIndicatorProps) {
  if (!isOnline) {
    return (
      <View style={styles.offline}>
        <Text>Offline - {pendingSync} changes pending</Text>
      </View>
    );
  }
  
  if (pendingSync > 0) {
    return (
      <View style={styles.syncing}>
        <ActivityIndicator />
        <Text>Syncing...</Text>
      </View>
    );
  }
  
  return null;
}

Best Practices

  1. Save locally first: Always save to local storage immediately
  2. Queue sync operations: Track what needs to sync
  3. Handle conflicts: Plan for data conflicts
  4. Show status: Let users know sync state
  5. Test offline: Test with network disabled
  6. Optimize storage: Don't store unnecessary data
  7. Compress data: Reduce storage usage

Testing Offline Behavior

describe('Offline functionality', () => {
  beforeEach(() => {
    // Mock offline
    NetInfo.fetch = jest.fn().mockResolvedValue({ isConnected: false });
  });
  
  it('saves locally when offline', async () => {
    const { result } = renderHook(() => useOfflineItems());
    
    await act(async () => {
      await result.current.createItem({ id: '1', title: 'Test' });
    });
    
    expect(result.current.items).toHaveLength(1);
    expect(result.current.pendingSync).toBe(1);
  });
  
  it('syncs when connection restored', async () => {
    const { result } = renderHook(() => useOfflineItems());
    
    // Create offline
    NetInfo.fetch = jest.fn().mockResolvedValue({ isConnected: false });
    await result.current.createItem({ id: '1', title: 'Test' });
    
    // Go online
    NetInfo.fetch = jest.fn().mockResolvedValue({ isConnected: true });
    await waitFor(() => {
      expect(result.current.pendingSync).toBe(0);
    });
  });
});

Conclusion

Offline-first apps provide:

  • Better UX: Instant responses
  • Reliability: Work anywhere
  • Efficiency: Reduced server load

Key principles:

  • Local-first: Data lives locally
  • Optimistic updates: Update UI immediately
  • Background sync: Sync when possible
  • Conflict resolution: Handle data conflicts

Build offline-first, and your users will thank you.