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
- Save locally first: Always save to local storage immediately
- Queue sync operations: Track what needs to sync
- Handle conflicts: Plan for data conflicts
- Show status: Let users know sync state
- Test offline: Test with network disabled
- Optimize storage: Don't store unnecessary data
- 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.
