Skip to content

Latest commit

 

History

History
737 lines (570 loc) · 21.8 KB

File metadata and controls

737 lines (570 loc) · 21.8 KB

Data Persistence

Navigation: ← Push Notifications | Native Modules & Bridge →


Table of Contents


Overview

Mobile apps need to persist data locally for offline access, caching, user preferences, and performance. React Native offers multiple storage solutions ranging from simple key-value stores to full relational databases. Choosing the right storage depends on data complexity, performance requirements, security needs, and offline capabilities.

This guide covers the storage solutions and patterns you'll encounter in React Native interviews from mid-level through architect roles.


Theory

Storage Options Overview

Solution Type Best For Performance
AsyncStorage Key-value (async) User prefs, small data Slow (JS bridge)
MMKV Key-value (sync) Fast prefs, session data 10-30x faster than AsyncStorage
SQLite Relational DB Structured data, queries Good with indexing
Realm Object DB Complex objects, sync Excellent (lazy loading)
Secure Storage Encrypted KV Tokens, credentials Moderate

AsyncStorage

Built into React Native community packages (@react-native-async-storage/async-storage). Simple async key-value API. All data is stored as strings — JSON serialization is manual. Not encrypted. Suitable for non-sensitive, small data.

MMKV

Built by WeChat, uses memory-mapped files for synchronous, high-performance key-value storage. Supports encryption. Ideal replacement for AsyncStorage when speed matters.

SQLite

Relational database via expo-sqlite, react-native-quick-sqlite, or @op-engineering/op-sqlite. Supports SQL queries, transactions, indexes, and migrations. Best for structured data with relationships.

Realm

Mobile-first object database (now owned by MongoDB). Schema-based with live objects, lazy loading, and optional MongoDB Atlas sync. Zero-copy architecture for performance.

Secure Storage

Platform-native encrypted storage:

  • iOS Keychain — Hardware-backed encryption on supported devices
  • Android Keystore — Hardware-backed key storage

Use react-native-keychain or expo-secure-store for tokens, passwords, and sensitive credentials.

Offline-First Architecture

Offline-first apps treat local storage as the primary data source and sync with the server in the background. Patterns include:

  • Cache-then-network — Show cached data immediately, update from server
  • Queue-based sync — Queue mutations offline, replay when online
  • Conflict resolution — Last-write-wins, CRDTs, or server-authoritative

Code Examples

AsyncStorage with Type Safety

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

const STORAGE_KEYS = {
  USER_PREFERENCES: '@app/user_preferences',
  ONBOARDING_COMPLETE: '@app/onboarding_complete',
} as const;

interface UserPreferences {
  theme: 'light' | 'dark' | 'system';
  language: string;
  notificationsEnabled: boolean;
}

export const storage = {
  async getPreferences(): Promise<UserPreferences | null> {
    try {
      const json = await AsyncStorage.getItem(STORAGE_KEYS.USER_PREFERENCES);
      return json ? (JSON.parse(json) as UserPreferences) : null;
    } catch (error) {
      console.error('Failed to read preferences:', error);
      return null;
    }
  },

  async setPreferences(prefs: UserPreferences): Promise<void> {
    await AsyncStorage.setItem(
      STORAGE_KEYS.USER_PREFERENCES,
      JSON.stringify(prefs),
    );
  },

  async clear(): Promise<void> {
    const keys = Object.values(STORAGE_KEYS);
    await AsyncStorage.multiRemove(keys);
  },
};

MMKV High-Performance Storage

import { MMKV } from 'react-native-mmkv';

export const mmkv = new MMKV({
  id: 'app-storage',
  encryptionKey: 'your-encryption-key-here',
});

interface StorageAdapter {
  getString: (key: string) => string | undefined;
  set: (key: string, value: string | number | boolean) => void;
  delete: (key: string) => void;
  getAllKeys: () => string[];
}

export const fastStorage: StorageAdapter = {
  getString: (key) => mmkv.getString(key),
  set: (key, value) => mmkv.set(key, value),
  delete: (key) => mmkv.delete(key),
  getAllKeys: () => mmkv.getAllKeys(),
};

// Zustand persist middleware adapter
import { StateStorage } from 'zustand/middleware';

export const mmkvStorage: StateStorage = {
  getItem: (name) => mmkv.getString(name) ?? null,
  setItem: (name, value) => mmkv.set(name, value),
  removeItem: (name) => mmkv.delete(name),
};

SQLite with Migrations

import * as SQLite from 'expo-sqlite';

const db = SQLite.openDatabaseSync('app.db');

const MIGRATIONS = [
  `CREATE TABLE IF NOT EXISTS notes (
    id TEXT PRIMARY KEY NOT NULL,
    title TEXT NOT NULL,
    body TEXT NOT NULL,
    created_at INTEGER NOT NULL,
    updated_at INTEGER NOT NULL,
    synced INTEGER DEFAULT 0
  )`,
  `CREATE INDEX IF NOT EXISTS idx_notes_updated ON notes(updated_at DESC)`,
];

export function runMigrations(): void {
  db.withTransactionSync(() => {
    for (const migration of MIGRATIONS) {
      db.execSync(migration);
    }
  });
}

export interface Note {
  id: string;
  title: string;
  body: string;
  createdAt: number;
  updatedAt: number;
  synced: boolean;
}

export const noteRepository = {
  getAll(): Note[] {
    const rows = db.getAllSync<{
      id: string;
      title: string;
      body: string;
      created_at: number;
      updated_at: number;
      synced: number;
    }>('SELECT * FROM notes ORDER BY updated_at DESC');

    return rows.map((row) => ({
      id: row.id,
      title: row.title,
      body: row.body,
      createdAt: row.created_at,
      updatedAt: row.updated_at,
      synced: row.synced === 1,
    }));
  },

  insert(note: Note): void {
    db.runSync(
      'INSERT INTO notes (id, title, body, created_at, updated_at, synced) VALUES (?, ?, ?, ?, ?, ?)',
      [note.id, note.title, note.body, note.createdAt, note.updatedAt, note.synced ? 1 : 0],
    );
  },

  getUnsynced(): Note[] {
    const rows = db.getAllSync<{
      id: string;
      title: string;
      body: string;
      created_at: number;
      updated_at: number;
      synced: number;
    }>('SELECT * FROM notes WHERE synced = 0');
    return rows.map((row) => ({
      id: row.id,
      title: row.title,
      body: row.body,
      createdAt: row.created_at,
      updatedAt: row.updated_at,
      synced: false,
    }));
  },
};

Secure Token Storage

import * as Keychain from 'react-native-keychain';

const SERVICE_NAME = 'com.myapp.auth';

interface AuthTokens {
  accessToken: string;
  refreshToken: string;
}

export const secureStorage = {
  async saveTokens(tokens: AuthTokens): Promise<void> {
    await Keychain.setGenericPassword(
      'auth',
      JSON.stringify(tokens),
      {
        service: SERVICE_NAME,
        accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
      },
    );
  },

  async getTokens(): Promise<AuthTokens | null> {
    const credentials = await Keychain.getGenericPassword({
      service: SERVICE_NAME,
    });
    if (!credentials) return null;
    return JSON.parse(credentials.password) as AuthTokens;
  },

  async clearTokens(): Promise<void> {
    await Keychain.resetGenericPassword({ service: SERVICE_NAME });
  },
};

Offline-First Sync Queue

import NetInfo from '@react-native-community/netinfo';
import { mmkv } from './mmkvStorage';
import { apiClient } from './apiClient';

interface SyncOperation {
  id: string;
  type: 'CREATE' | 'UPDATE' | 'DELETE';
  entity: string;
  payload: Record<string, unknown>;
  timestamp: number;
  retries: number;
}

const QUEUE_KEY = 'sync_queue';

class SyncEngine {
  private isProcessing = false;

  enqueue(operation: Omit<SyncOperation, 'id' | 'timestamp' | 'retries'>): void {
    const queue = this.getQueue();
    queue.push({
      ...operation,
      id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
      timestamp: Date.now(),
      retries: 0,
    });
    mmkv.set(QUEUE_KEY, JSON.stringify(queue));
    this.processQueue();
  }

  private getQueue(): SyncOperation[] {
    const raw = mmkv.getString(QUEUE_KEY);
    return raw ? (JSON.parse(raw) as SyncOperation[]) : [];
  }

  async processQueue(): Promise<void> {
    if (this.isProcessing) return;

    const netState = await NetInfo.fetch();
    if (!netState.isConnected) return;

    this.isProcessing = true;
    const queue = this.getQueue();
    const remaining: SyncOperation[] = [];

    for (const op of queue) {
      try {
        await this.executeOperation(op);
      } catch (error) {
        if (op.retries < 3) {
          remaining.push({ ...op, retries: op.retries + 1 });
        }
      }
    }

    mmkv.set(QUEUE_KEY, JSON.stringify(remaining));
    this.isProcessing = false;
  }

  private async executeOperation(op: SyncOperation): Promise<void> {
    switch (op.type) {
      case 'CREATE':
        await apiClient.post(`/${op.entity}`, op.payload);
        break;
      case 'UPDATE':
        await apiClient.put(`/${op.entity}/${op.payload.id}`, op.payload);
        break;
      case 'DELETE':
        await apiClient.delete(`/${op.entity}/${op.payload.id}`);
        break;
    }
  }
}

export const syncEngine = new SyncEngine();

NetInfo.addEventListener((state) => {
  if (state.isConnected) {
    syncEngine.processQueue();
  }
});

Interview Questions & Answers

Q1: When would you choose AsyncStorage vs MMKV vs SQLite?

Answer:

Criteria AsyncStorage MMKV SQLite
Data size < 1MB < 10MB Unlimited
Data structure Simple key-value Simple key-value Relational with queries
Read performance Slow (async bridge) Very fast (sync, mmap) Good with indexes
Encryption No Yes (built-in) Via SQLCipher extension
Querying Key lookup only Key lookup only Full SQL
Complexity Minimal Minimal Moderate (migrations, schema)

Decision tree:

  • User preferences, feature flags, onboarding state → MMKV (or AsyncStorage for simplicity)
  • Auth tokens, credentials → Keychain/Keystore (never AsyncStorage)
  • Cached API responses, structured lists → SQLite
  • Complex objects with relationships, live queries → Realm
  • Offline-first with sync → SQLite or Realm with sync engine

Interview tip: Mention that AsyncStorage is being replaced by MMKV in most production apps due to performance. AsyncStorage operations can take 50-200ms per call due to bridge serialization.


Q2: What are the limitations of AsyncStorage?

Answer:

  1. Performance — Every read/write crosses the JS-native bridge asynchronously. Batch operations with multiGet/multiSet help but are still slow.

  2. No encryption — Data stored in plaintext. Accessible on rooted/jailbroken devices.

  3. String-only — Must JSON.stringify/parse everything manually.

  4. Size limits — No hard limit, but performance degrades significantly above ~6MB on Android.

  5. No querying — Can only retrieve by exact key. No search, filter, or sort.

  6. No transactions — Race conditions possible with concurrent writes.

  7. No schema — No validation or migration support.

  8. Main thread blocking on large reads — JSON.parse of large strings blocks JS thread.

// Problematic — storing large datasets
await AsyncStorage.setItem('products', JSON.stringify(tenThousandProducts));

// Better — use SQLite or MMKV for large/fast data

For new projects, default to MMKV for key-value and SQLite for structured data. Reserve AsyncStorage for legacy compatibility or extremely simple apps.


Q3: How does MMKV achieve better performance than AsyncStorage?

Answer:

MMKV (Memory-Mapped Key-Value) uses fundamentally different storage architecture:

AsyncStorage:

  • JS call → Bridge serialization → Native module → File I/O → Bridge deserialization → JS callback
  • Fully asynchronous — every operation is a round trip
  • Stores data as files managed by SQLite (Android) or files (iOS)

MMKV:

  • Memory-mapped files — File mapped directly into process memory space
  • Synchronous API — No bridge round trip for reads/writes
  • Protobuf encoding — Compact binary serialization
  • Multi-process safe — Lock-free concurrent access
  • Incremental sync — Only changed data written to disk

Performance benchmarks (typical):

  • MMKV read: ~0.1ms (sync)
  • AsyncStorage read: ~3-50ms (async, bridge overhead)
  • MMKV is 10-30x faster for typical operations
// MMKV — synchronous, no await needed
const value = mmkv.getString('key');
mmkv.set('key', 'value');

// AsyncStorage — always async
const value = await AsyncStorage.getItem('key');
await AsyncStorage.setItem('key', 'value');

Trade-off: MMKV is synchronous, so extremely large reads could block the JS thread. For typical key-value sizes (< 1MB), this is negligible.


Q4: How do you implement database migrations in SQLite for React Native?

Answer:

Use a version-tracked migration system:

const CURRENT_VERSION = 3;

const migrations: Record<number, string[]> = {
  1: [
    `CREATE TABLE IF NOT EXISTS users (
      id TEXT PRIMARY KEY,
      name TEXT NOT NULL
    )`,
  ],
  2: [
    `ALTER TABLE users ADD COLUMN email TEXT`,
  ],
  3: [
    `CREATE TABLE IF NOT EXISTS posts (
      id TEXT PRIMARY KEY,
      user_id TEXT REFERENCES users(id),
      title TEXT NOT NULL,
      body TEXT
    )`,
    `CREATE INDEX idx_posts_user ON posts(user_id)`,
  ],
};

async function migrate(db: SQLiteDatabase): Promise<void> {
  db.execSync(`
    CREATE TABLE IF NOT EXISTS schema_version (
      version INTEGER NOT NULL
    )
  `);

  const result = db.getFirstSync<{ version: number }>(
    'SELECT version FROM schema_version LIMIT 1',
  );
  let currentVersion = result?.version ?? 0;

  db.withTransactionSync(() => {
    while (currentVersion < CURRENT_VERSION) {
      currentVersion++;
      const scripts = migrations[currentVersion];
      if (scripts) {
        for (const sql of scripts) {
          db.execSync(sql);
        }
      }
      db.runSync(
        'INSERT OR REPLACE INTO schema_version (version) VALUES (?)',
        [currentVersion],
      );
    }
  });
}

Best practices:

  • Never modify existing migrations — always add new version
  • Wrap migrations in transactions
  • Test migrations with production-size data
  • Handle ALTER TABLE limitations on SQLite (can't drop columns in older versions — use table recreation pattern)
  • Consider libraries: drizzle-orm, typeorm, or expo-sqlite with custom migration runner

Q5: How do you securely store sensitive data in React Native?

Answer:

Never use AsyncStorage or MMKV (unencrypted) for:

  • Auth tokens (access/refresh)
  • Passwords or PINs
  • API secrets
  • PII (personally identifiable information)
  • Encryption keys

Use platform secure storage:

import * as Keychain from 'react-native-keychain';

// iOS: Keychain Services (hardware encryption on Secure Enclave devices)
// Android: EncryptedSharedPreferences + Keystore

await Keychain.setGenericPassword('username', 'token', {
  service: 'com.app.auth',
  accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
  securityLevel: Keychain.SECURITY_LEVEL.SECURE_HARDWARE, // Android
});

Security layers for defense in depth:

  1. Secure storage for tokens (Keychain/Keystore)
  2. Certificate pinning for API communication
  3. Biometric authentication before accessing sensitive data
  4. Root/jailbreak detection for high-security apps
  5. Auto-clear tokens on background after timeout
  6. No logging of sensitive values in production

Expo alternative: expo-secure-store provides similar Keychain/Keystore access.

Interview follow-up: Keychain data persists across app updates but is removed on uninstall (iOS). Android Keystore keys can be invalidated on biometric enrollment changes.


Q6: What is Realm, and when would you choose it over SQLite?

Answer:

Realm is a mobile-first object database that stores data as native objects rather than rows:

Feature Realm SQLite
Data model Objects with schema Tables with SQL
Queries Realm Query Language SQL
Relationships Object references (lazy) Foreign keys + JOINs
Live updates Built-in (Observable) Manual (polling/triggers)
Sync MongoDB Atlas Device Sync Custom sync layer
Learning curve Medium (proprietary API) Low (standard SQL)
Migration Schema versioning built-in Manual SQL migrations

Choose Realm when:

  • Complex object graphs with relationships
  • Need live, reactive data updates in UI
  • Using MongoDB Atlas for backend sync
  • Team prefers object-oriented data access

Choose SQLite when:

  • Team knows SQL well
  • Need complex queries (JOINs, aggregations, window functions)
  • Want ORM flexibility (Drizzle, TypeORM)
  • Open-source, no vendor dependency
  • Existing backend with REST/GraphQL (no Realm sync needed)
// Realm — object-oriented
const tasks = realm.objects('Task').filtered('isComplete == false');

// SQLite — relational
const tasks = db.getAllSync(
  'SELECT * FROM tasks WHERE is_complete = 0 ORDER BY created_at DESC',
);

Note: Realm SDK has evolved — check current MongoDB Realm / Atlas Device Sync status for your project's needs.


Q7: How do you implement an offline-first architecture in React Native?

Answer:

Offline-first treats local storage as the source of truth with background sync:

Architecture layers:

UI → Local Database (SQLite/Realm) → Sync Engine → Remote API
         ↑ read/write here              ↑ background sync

Implementation steps:

  1. Write locally first — All mutations go to local DB immediately
  2. Mark as unsyncedsynced: false flag on modified records
  3. Queue mutations — Store operation type, payload, timestamp
  4. Background sync — Process queue when NetInfo reports connectivity
  5. Conflict resolution — Define strategy (last-write-wins, server wins, manual merge)
  6. UI reads local data — Never block UI on network
// User creates a note — instant UI update
async function createNote(title: string, body: string) {
  const note = { id: uuid(), title, body, synced: false, updatedAt: Date.now() };
  noteRepository.insert(note);        // Local first
  syncEngine.enqueue({                // Queue for sync
    type: 'CREATE',
    entity: 'notes',
    payload: note,
  });
  return note;
}

With TanStack Query (simpler approach):

  • networkMode: 'offlineFirst' — Use cache when offline
  • persistQueryClient — Persist cache to storage
  • Optimistic mutations for instant UI

Conflict resolution strategies:

  • Last-write-wins — Compare updatedAt timestamps
  • Server-authoritative — Server version always wins
  • CRDTs — Conflict-free replicated data types (advanced)
  • Manual merge — Present conflicts to user

Q8: How do you handle data persistence with Zustand or Redux?

Answer:

Both support persist middleware that serializes state to storage:

Zustand with MMKV:

import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { mmkvStorage } from './mmkvStorage';

interface AppState {
  theme: 'light' | 'dark';
  setTheme: (theme: 'light' | 'dark') => void;
}

export const useAppStore = create<AppState>()(
  persist(
    (set) => ({
      theme: 'light',
      setTheme: (theme) => set({ theme }),
    }),
    {
      name: 'app-storage',
      storage: createJSONStorage(() => mmkvStorage),
      partialize: (state) => ({ theme: state.theme }),
    },
  ),
);

Redux Toolkit with redux-persist:

import { configureStore } from '@reduxjs/toolkit';
import { persistStore, persistReducer } from 'redux-persist';
import AsyncStorage from '@react-native-async-storage/async-storage';

const persistConfig = {
  key: 'root',
  storage: AsyncStorage,
  whitelist: ['auth', 'preferences'],
  blacklist: ['temporaryFormData'],
};

const persistedReducer = persistReducer(persistConfig, rootReducer);

Key considerations:

  • partialize/whitelist — Only persist necessary state (not loading flags, errors)
  • Migration — Handle state shape changes between app versions
  • Rehydration timing — Show splash/loading until persist rehydrates
  • Secure data — Don't persist tokens in Zustand/Redux persist; use Keychain separately
  • Performance — Use MMKV adapter instead of AsyncStorage for faster rehydration

Best Practices Summary

Practice Reason
Use MMKV over AsyncStorage for key-value 10-30x performance improvement
Use Keychain/Keystore for tokens Encrypted, hardware-backed storage
Use SQLite for structured/queryable data Proper indexing and relationships
Implement database migrations from day one Schema evolves; migrations prevent data loss
Write locally first in offline-first apps Instant UI, sync in background
Partialize persisted state Don't persist transient UI state
Batch AsyncStorage operations multiGet/multiSet reduce bridge calls
Test on low-end devices with large datasets Storage performance varies by device

Navigation: ← Push Notifications | Native Modules & Bridge →