Building an Offline-First Application with React Native and Expo
In today's mobile-first world, users expect applications to work seamlessly regardless of network conditions. Whether they're on a plane, in a subway, or simply in an area with poor connectivity, your app should remain fully functional. This is where the offline-first architecture shines.

Offline-First Application with React Native Expo
In today's mobile-first world, users expect applications to work seamlessly regardless of network conditions. Whether they're on a plane, in a subway, or simply in an area with poor connectivity, your app should remain fully functional. This is where the offline-first architecture shines.
In this guide, we'll walk through building an offline-first journal application using React Native, Expo, and SQLite. By the end, you'll understand the core principles and patterns that make offline-first apps work.
The key architectural pieces are:
- SQLite (via expo-sqlite) for persistent local storage
- Repository Pattern for clean data access abstraction
- Zustand for in-memory caching and reactive UI updates
- Sync fields (status, version) for future cloud integration
What is Offline-First?
Offline-first is an architectural approach where:
- Local storage is the source of truth- All data is stored locally first
- No network dependency for core functionality - Users can create, read, update, and delete data without internet
- Instant UI updates- Changes appear immediately without waiting for network responses
- Sync-ready design - The architecture supports eventual cloud sync without major refactoring
Architecture Overview
Here's the layered architecture we'll implement:
Project Setup
First, create a new Expo project and install the required dependencies:
# Create a new Expo project
npx create-expo-app@latest my-offline-app --template blank-typescript
cd my-offline-app
# Install core dependencies
npx expo install expo-sqlite
# Install state management and utilities
npm install zustand uuid
npm install -D @types/uuid
# Important: Install the crypto polyfill for UUID generation
npm install react-native-get-random-valuesImportant: React Native doesn't have the Web Crypto API, so you need to import the polyfill at the very top of your app's entry point:
inside: app/_layout.tsx (must be the FIRST import)
import 'react-native-get-random-values';
Step 1: Define Your Data Types
Start by defining clear TypeScript interfaces for your data models:
export type SyncStatus = 'pending' | 'synced' | 'conflict';
export interface JournalEntry {
id: string;
title: string;
content: string;
createdAt: Date;
updatedAt: Date;
deletedAt?: Date; // For soft delete
syncStatus: SyncStatus; // For future cloud sync
syncVersion: number; // For conflict resolution
}
export interface CreateEntryInput {
title: string;
content: string;
}
export interface UpdateEntryInput {
title?: string;
content?: string;
}Notice the `syncStatus` and `syncVersion` fields - these prepare your app for future cloud synchronization without requiring schema changes.
Step 2: Set Up the Database Layer
The database layer handles SQLite initialization and schema management. We use the singleton pattern to ensure only one database connection exists:
import * as SQLite from 'expo-sqlite';
const DATABASE_NAME = 'journal.db';
class DatabaseService {
private db: SQLite.SQLiteDatabase | null = null;
private static instance: DatabaseService;
private isInitialized = false;
static getInstance(): DatabaseService {
if (!DatabaseService.instance) {
DatabaseService.instance = new DatabaseService();
}
return DatabaseService.instance;
}
async initialize(): Promise<void> {
if (this.isInitialized) {
return;
}
this.db = await SQLite.openDatabaseAsync(DATABASE_NAME);
await this.runMigrations();
this.isInitialized = true;
}
private async runMigrations(): Promise<void> {
if (!this.db) {
throw new Error('Database not initialized');
}
// Create entries table with sync-ready fields
await this.db.execAsync(`
CREATE TABLE IF NOT EXISTS entries(
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT,
sync_status TEXT DEFAULT 'pending',
sync_version INTEGER DEFAULT 0
);
`);
// Create indexes for common queries
await this.db.execAsync(`
CREATE INDEX IF NOT EXISTS idx_entries_created_at
ON entries(created_at DESC);
`);
await this.db.execAsync(`
CREATE INDEX IF NOT EXISTS idx_entries_deleted_at
ON entries(deleted_at);
`);
}
getDatabase(): SQLite.SQLiteDatabase {
if (!this.db) {
throw new Error('Database not initialized. Call initialize() first.');
}
return this.db;
}
}
export const databaseService = DatabaseService.getInstance();Key Design Decisions:
- Singleton Pattern - Ensures one database connection across the app
- Soft Delete - The `deleted_at` column allows recovery and proper sync handling
- Sync Fields - `sync_status` and `sync_version` prepare for future cloud sync
- Indexes - Speed up common queries like fetching recent entries
Step 3: Implement the Repository Pattern
The repository pattern abstracts data access, making it easy to swap storage implementations and test your code:
import { v4 as uuidv4 } from 'uuid';
import { databaseService } from './database';
import { JournalEntry, CreateEntryInput, UpdateEntryInput } from '@/types';
// Database row type (snake_case from SQLite)
interface EntryRow {
id: string;
title: string;
content: string;
created_at: string;
updated_at: string;
deleted_at: string | null;
sync_status: string;
sync_version: number;
}
// Map database row to domain model
function mapRowToEntry(row: EntryRow): JournalEntry {
return {
id: row.id,
title: row.title,
content: row.content,
createdAt: new Date(row.created_at),
updatedAt: new Date(row.updated_at),
deletedAt: row.deleted_at ? new Date(row.deleted_at) : undefined,
syncStatus: row.sync_status as SyncStatus,
syncVersion: row.sync_version,
};
}
class EntryRepository {
private static instance: EntryRepository;
static getInstance(): EntryRepository {
if (!EntryRepository.instance) {
EntryRepository.instance = new EntryRepository();
}
return EntryRepository.instance;
}
// Fetch all non-deleted entries
async getAll(): Promise<JournalEntry[]> {
const db = databaseService.getDatabase();
const rows = await db.getAllAsync<EntryRow>(
`SELECT * FROM entries
WHERE deleted_at IS NULL
ORDER BY created_at DESC`
);
return rows.map(mapRowToEntry);
}
// Fetch a single entry by ID
async getById(id: string): Promise<JournalEntry | null> {
const db = databaseService.getDatabase();
const row = await db.getFirstAsync<EntryRow>(
`SELECT * FROM entries WHERE id = ?`,
[id]
);
return row ? mapRowToEntry(row) : null;
}
// Create a new entry
async create(input: CreateEntryInput): Promise<JournalEntry> {
const db = databaseService.getDatabase();
const id = uuidv4();
const now = new Date().toISOString();
await db.runAsync(
`INSERT INTO entries(id, title, content, created_at, updated_at, sync_status, sync_version)
VALUES(?, ?, ?, ?, ?, 'pending', 0)`,
[id, input.title, input.content, now, now]
);
const entry = await this.getById(id);
if (!entry) {
throw new Error('Failed to create entry');
}
return entry;
}
// Update an existing entry
async update(id: string, input: UpdateEntryInput): Promise<JournalEntry> {
const db = databaseService.getDatabase();
const now = new Date().toISOString();
const updates: string[] = [];
const values: (string | null)[] = [];
if (input.title !== undefined) {
updates.push('title = ?');
values.push(input.title);
}
if (input.content !== undefined) {
updates.push('content = ?');
values.push(input.content);
}
if (updates.length > 0) {
// Always update timestamp, sync status, and version
updates.push('updated_at = ?');
values.push(now);
updates.push("sync_status = 'pending'");
updates.push('sync_version = sync_version + 1');
values.push(id);
await db.runAsync(
`UPDATE entries SET ${updates.join(', ')} WHERE id = ?`,
values
);
}
const entry = await this.getById(id);
if (!entry) {
throw new Error('Entry not found');
}
return entry;
}
// Soft delete - keeps data for sync and recovery
async softDelete(id: string): Promise<void> {
const db = databaseService.getDatabase();
const now = new Date().toISOString();
await db.runAsync(
`UPDATE entries
SET deleted_at = ?, sync_status = 'pending', sync_version = sync_version + 1
WHERE id = ?`,
[now, id]
);
}
// Restore a soft-deleted entry
async restore(id: string): Promise<void> {
const db = databaseService.getDatabase();
await db.runAsync(
`UPDATE entries
SET deleted_at = NULL, sync_status = 'pending', sync_version = sync_version + 1
WHERE id = ?`,
[id]
);
}
// Get all soft-deleted entries (for trash/recovery UI)
async getDeleted(): Promise<JournalEntry[]> {
const db = databaseService.getDatabase();
const rows = await db.getAllAsync<EntryRow>(
`SELECT * FROM entries
WHERE deleted_at IS NOT NULL
ORDER BY deleted_at DESC`
);
return rows.map(mapRowToEntry);
}
// Permanently delete old entries (cleanup)
async purgeOldDeleted(daysOld: number = 30): Promise<number> {
const db = databaseService.getDatabase();
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - daysOld);
const result = await db.runAsync(
'DELETE FROM entries WHERE deleted_at < ?',
[cutoffDate.toISOString()]
);
return result.changes;
}
}
export const entryRepository = EntryRepository.getInstance();Key Patterns:
- Soft Delete - `
softDelete()` sets `deleted_at` instead of removing the row - Version Tracking - Every mutation increments `sync_version` for conflict detection
- Pending Status - All changes mark `sync_status = 'pending'` for future sync
- Mapper Functions - Convert between database rows (snake_case) and domain models (camelCase)
Step 4: State Management with Zustand
Zustand provides a lightweight, hook-based store that serves as an in-memory cache for our SQLite data:
import { create } from 'zustand';
import { entryRepository } from '@/services/entryRepository';
import { JournalEntry, CreateEntryInput, UpdateEntryInput } from '@/types';
interface EntryState {
entries: JournalEntry[];
deletedEntries: JournalEntry[];
isLoading: boolean;
error: string | null;
// Actions
loadEntries: () => Promise<void>;
loadDeletedEntries: () => Promise<void>;
createEntry: (input: CreateEntryInput) => Promise<JournalEntry>;
updateEntry: (id: string, input: UpdateEntryInput) => Promise<JournalEntry>;
deleteEntry: (id: string) => Promise<void>;
restoreEntry: (id: string) => Promise<void>;
getEntryById: (id: string) => JournalEntry | undefined;
}
export const useEntryStore = create<EntryState>((set, get) => ({
entries: [],
deletedEntries: [],
isLoading: false,
error: null,
loadEntries: async () => {
set({ isLoading: true, error: null });
try {
const entries = await entryRepository.getAll();
set({ entries, isLoading: false });
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to load entries',
isLoading: false,
});
}
},
loadDeletedEntries: async () => {
try {
const deletedEntries = await entryRepository.getDeleted();
set({ deletedEntries });
} catch (error) {
console.error('Failed to load deleted entries:', error);
}
},
createEntry: async (input) => {
set({ error: null });
try {
// 1. Write to SQLite
const entry = await entryRepository.create(input);
// 2. Update in-memory cache (optimistic update)
set((state) => ({
entries: [entry, ...state.entries],
}));
return entry;
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to create entry';
set({ error: message });
throw error;
}
},
updateEntry: async (id, input) => {
set({ error: null });
try {
const entry = await entryRepository.update(id, input);
set((state) => ({
entries: state.entries.map((e) => (e.id === id ? entry : e)),
}));
return entry;
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to update entry';
set({ error: message });
throw error;
}
},
deleteEntry: async (id) => {
set({ error: null });
try {
await entryRepository.softDelete(id);
const entry = get().entries.find((e) => e.id === id);
set((state) => ({
// Remove from active entries
entries: state.entries.filter((e) => e.id !== id),
// Add to deleted entries
deletedEntries: entry
? [{ ...entry, deletedAt: new Date() }, ...state.deletedEntries]
: state.deletedEntries,
}));
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to delete entry';
set({ error: message });
throw error;
}
},
restoreEntry: async (id) => {
set({ error: null });
try {
await entryRepository.restore(id);
const entry = get().deletedEntries.find((e) => e.id === id);
if (entry) {
const restoredEntry = { ...entry, deletedAt: undefined };
set((state) => ({
deletedEntries: state.deletedEntries.filter((e) => e.id !== id),
entries: [restoredEntry, ...state.entries].sort(
(a, b) => b.createdAt.getTime() - a.createdAt.getTime()
),
}));
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to restore entry';
set({ error: message });
throw error;
}
},
getEntryById: (id) => {
return get().entries.find((e) => e.id === id);
},
}));The Offline-First Data Flow:
Step 5: Initialize on App Start
Initialize the database when your app starts:
import 'react-native-get-random-values'; // MUST be first!
import { useEffect, useState } from 'react';
import { View, ActivityIndicator } from 'react-native';
import { databaseService } from '@/services/database';
export default function RootLayout() {
const [isReady, setIsReady] = useState(false);
useEffect(() => {
async function initialize() {
try {
await databaseService.initialize();
setIsReady(true);
} catch (error) {
console.error('Failed to initialize database:', error);
setIsReady(true); // Still show app so user can see error
}
}
initialize();
}, []);
if (!isReady) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" />
</View>
);
}
return (
// Your app content
);
}
Step 6: Using the Store in Components
Now you can use the store in your components with zero network latency:
import { useEffect } from 'react';
import { View, FlatList, Text, Button } from 'react-native';
import { useEntryStore } from '@/stores/entryStore';
export default function HomeScreen() {
const { entries, isLoading, loadEntries, createEntry } = useEntryStore();
useEffect(() => {
loadEntries();
}, []);
const handleCreateEntry = async () => {
await createEntry({
title: 'New Entry',
content: 'This was created offline!',
});
// UI updates instantly - no loading spinner needed
};
return (
<View style={{ flex: 1 }}>
<Button title="Create Entry" onPress={handleCreateEntry} />
<FlatList
data={entries}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<View style={{ padding: 16 }}>
<Text style={{ fontWeight: 'bold' }}>{item.title}</Text>
<Text>{item.content}</Text>
</View>
)}
/>
</View>
);
}Conclusion
Building offline-first applications requires a shift in thinking - the local database is your source of truth, not the server. This approach provides:
- Instant UI feedback - No waiting for network requests
- Reliability - App works regardless of connectivity
- Better UX - Users never see "No internet connection" errors for core features
- Sync-ready - Easy to add cloud sync later
Start with this foundation, and you'll have an app that delights users with its responsiveness and reliability.
