AMAustin Mula
Dev Exploits

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.

Austin Mula
12 min read
austin mula dev exploits code series

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:

  1. Local storage is the source of truth- All data is stored locally first
  2. No network dependency for core functionality - Users can create, read, update, and delete data without internet
  3. Instant UI updates- Changes appear immediately without waiting for network responses
  4. 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:

Bash
# 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-values

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

src/types/entry.ts
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:

src/services/database.ts
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:

  1. Singleton Pattern - Ensures one database connection across the app
  2. Soft Delete - The `deleted_at` column allows recovery and proper sync handling
  3. Sync Fields - `sync_status` and `sync_version` prepare for future cloud sync
  4. 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:

src/services/entryRepository.ts
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:

  1. Soft Delete - `softDelete()` sets `deleted_at` instead of removing the row
  2. Version Tracking - Every mutation increments `sync_version` for conflict detection
  3. Pending Status - All changes mark `sync_status = 'pending'` for future sync
  4. 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:

src/stores/entryStore.ts
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:

app/_layout.tsx
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:

app/index.tsx
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.