Skip to main content

Module 7: Data Management and Networking

Local Storage Options

Persisting data on the device for offline access and faster app startup

🎯 Learning Objectives

  • Understand when and why to use local storage in mobile apps
  • Use AsyncStorage for general-purpose key-value storage
  • Secure sensitive data with expo-secure-store
  • Achieve high-performance storage with react-native-mmkv
  • Choose the right storage solution for different use cases
  • Implement storage abstractions for cleaner code
  • Handle storage migrations when data structures change

Why Local Storage?

Mobile apps need local storage for many reasons. Unlike web apps where you might rely on server-side sessions and localStorage for simple things, mobile apps often need to work offline, start quickly, and remember user preferences across sessions.

📱 Common Use Cases for Local Storage

  • Authentication tokens — Keep users logged in between app launches
  • User preferences — Theme, language, notification settings
  • Cached data — Show content immediately while fetching updates
  • Offline data — Let users work without internet connectivity
  • Draft content — Save unsent messages or unfinished forms
  • Onboarding state — Track if user has completed tutorial
  • App state — Restore user's position after app restart

The key question isn't whether you need local storage—you almost certainly do. The question is which storage solution fits your specific needs.

Benefits of Local Storage 📱 Device Local Storage Instant 📴 Offline 💾 Persistent 🔒 Secure No network latency Data survives app restarts Encrypted options available

The Storage Landscape

React Native offers several storage options, each with different tradeoffs. Here's the landscape:

flowchart TB
    subgraph options["Storage Options"]
        AS["AsyncStorage
General purpose"] SS["SecureStore
Sensitive data"] MMKV["MMKV
High performance"] SQLite["SQLite
Relational data"] FS["FileSystem
Large files"] end subgraph usecases["Use Cases"] U1["Settings, preferences"] U2["Auth tokens, passwords"] U3["Frequently accessed data"] U4["Complex queries"] U5["Images, documents"] end AS --> U1 SS --> U2 MMKV --> U3 SQLite --> U4 FS --> U5 style AS fill:#e3f2fd,stroke:#2196F3 style SS fill:#fce4ec,stroke:#E91E63 style MMKV fill:#fff3e0,stroke:#FF9800 style SQLite fill:#e8f5e9,stroke:#4CAF50 style FS fill:#f3e5f5,stroke:#9C27B0

In this lesson, we'll focus on the three most common options for key-value storage: AsyncStorage, SecureStore, and MMKV. SQLite and FileSystem are covered in other lessons.

📖 Key-Value Storage

All three options we'll cover are key-value stores—they save data as string keys mapped to string values. Think of them like a persistent JavaScript object or a simple database with just two columns: key and value.

AsyncStorage: The Standard Choice

AsyncStorage is the go-to storage solution for React Native apps. It's simple, well-documented, and works for most use cases. Think of it as localStorage for mobile, but asynchronous.

Installation

npx expo install @react-native-async-storage/async-storage

Basic Operations

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

// ============= STORING DATA =============

// Store a string
await AsyncStorage.setItem('username', 'john_doe');

// Store an object (must stringify!)
const user = { id: 1, name: 'John', email: 'john@example.com' };
await AsyncStorage.setItem('user', JSON.stringify(user));

// Store multiple items at once (more efficient)
await AsyncStorage.multiSet([
  ['theme', 'dark'],
  ['language', 'en'],
  ['notifications', 'true'],
]);


// ============= RETRIEVING DATA =============

// Get a string
const username = await AsyncStorage.getItem('username');
console.log(username); // 'john_doe' or null if not found

// Get and parse an object
const userJson = await AsyncStorage.getItem('user');
const user = userJson ? JSON.parse(userJson) : null;

// Get multiple items at once
const values = await AsyncStorage.multiGet(['theme', 'language']);
// Returns: [['theme', 'dark'], ['language', 'en']]

// Convert to object for easier access
const settings = Object.fromEntries(values);
// { theme: 'dark', language: 'en' }


// ============= REMOVING DATA =============

// Remove a single item
await AsyncStorage.removeItem('username');

// Remove multiple items
await AsyncStorage.multiRemove(['theme', 'language', 'notifications']);

// Clear ALL storage (use carefully!)
await AsyncStorage.clear();


// ============= OTHER OPERATIONS =============

// Get all keys
const allKeys = await AsyncStorage.getAllKeys();
// ['user', 'theme', 'language', ...]

// Merge object (useful for partial updates)
// Existing: { name: 'John', email: 'john@example.com' }
await AsyncStorage.mergeItem('user', JSON.stringify({ email: 'newemail@example.com' }));
// Result: { name: 'John', email: 'newemail@example.com' }

⚠️ Always Handle Errors

AsyncStorage operations can fail (storage full, corrupted data, etc.). Always wrap in try-catch:

try {
  await AsyncStorage.setItem('key', 'value');
} catch (error) {
  console.error('Failed to save:', error);
}

Creating a Type-Safe Storage Hook

Raw AsyncStorage is verbose. Let's create a hook that handles JSON serialization and provides better TypeScript support:

import { useState, useEffect, useCallback } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';

// Generic hook for any stored value
export function useAsyncStorage<T>(
  key: string,
  initialValue: T
): [T, (value: T) => Promise<void>, boolean] {
  const [storedValue, setStoredValue] = useState<T>(initialValue);
  const [isLoading, setIsLoading] = useState(true);

  // Load value on mount
  useEffect(() => {
    const loadValue = async () => {
      try {
        const item = await AsyncStorage.getItem(key);
        if (item !== null) {
          setStoredValue(JSON.parse(item));
        }
      } catch (error) {
        console.error(`Error loading ${key}:`, error);
      } finally {
        setIsLoading(false);
      }
    };

    loadValue();
  }, [key]);

  // Save value
  const setValue = useCallback(async (value: T) => {
    try {
      setStoredValue(value);
      await AsyncStorage.setItem(key, JSON.stringify(value));
    } catch (error) {
      console.error(`Error saving ${key}:`, error);
      throw error;
    }
  }, [key]);

  return [storedValue, setValue, isLoading];
}

// Usage
function SettingsScreen() {
  const [theme, setTheme, isLoading] = useAsyncStorage<'light' | 'dark'>(
    'theme',
    'light'
  );

  if (isLoading) {
    return <ActivityIndicator />;
  }

  return (
    <View>
      <Text>Current theme: {theme}</Text>
      <Button
        title="Toggle Theme"
        onPress={() => setTheme(theme === 'light' ? 'dark' : 'light')}
      />
    </View>
  );
}

AsyncStorage Limitations

📋 Know the Limits

  • Performance: Slower than MMKV, especially for frequent reads/writes
  • Size limit: ~6MB on Android by default (can be increased)
  • Strings only: Must serialize/deserialize objects manually
  • No encryption: Data is stored in plain text
  • Async only: No synchronous access (can complicate app initialization)

✅ When to Use AsyncStorage

  • User preferences and settings
  • Non-sensitive cached data
  • Onboarding/tutorial completion flags
  • Simple app state that doesn't need encryption
  • When you need a simple, well-supported solution

SecureStore: For Sensitive Data

When you're storing sensitive information—authentication tokens, passwords, API keys—you need encryption. expo-secure-store uses the device's secure hardware to encrypt data.

How It Works

SecureStore: Hardware-Backed Encryption Your App "password123" setItemAsync SecureStore API encrypt iOS: Keychain Android: Keystore 🔐 Hardware Encryption Encrypted Storage 🔒 Data is encrypted using device-specific keys stored in secure hardware Even if device is compromised, keys remain protected

Installation

npx expo install expo-secure-store

Basic Operations

import * as SecureStore from 'expo-secure-store';

// ============= STORING DATA =============

// Store a string (automatically encrypted)
await SecureStore.setItemAsync('authToken', 'eyJhbGciOiJIUzI1...');

// Store with options
await SecureStore.setItemAsync('refreshToken', 'abc123', {
  // Require device authentication (FaceID/TouchID/PIN) to access
  requireAuthentication: true,
  // Custom prompt for biometric auth
  authenticationPrompt: 'Authenticate to access your account',
});


// ============= RETRIEVING DATA =============

// Get a value
const token = await SecureStore.getItemAsync('authToken');
// Returns string or null if not found

// Will prompt for biometric auth if requireAuthentication was true
const refreshToken = await SecureStore.getItemAsync('refreshToken');


// ============= REMOVING DATA =============

await SecureStore.deleteItemAsync('authToken');


// ============= CHECKING AVAILABILITY =============

// Check if SecureStore is available on this device
const isAvailable = await SecureStore.isAvailableAsync();
if (!isAvailable) {
  // Fall back to AsyncStorage with warning
  console.warn('SecureStore not available, using AsyncStorage');
}

⚠️ SecureStore Limitations

  • Size limit: Values must be ≤ 2048 bytes
  • Strings only: No objects (must stringify small objects)
  • Key restrictions: Keys can only contain alphanumeric characters, ., -, and _
  • Not for large data: Designed for tokens and credentials, not cached API responses

Authentication Token Management

Here's a complete example of managing auth tokens with SecureStore:

import * as SecureStore from 'expo-secure-store';

const TOKEN_KEY = 'auth_token';
const REFRESH_TOKEN_KEY = 'refresh_token';

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

class TokenStorage {
  // Save tokens after login
  static async saveTokens(tokens: AuthTokens): Promise<void> {
    await Promise.all([
      SecureStore.setItemAsync(TOKEN_KEY, tokens.accessToken),
      SecureStore.setItemAsync(REFRESH_TOKEN_KEY, tokens.refreshToken),
    ]);
  }

  // Get access token
  static async getAccessToken(): Promise<string | null> {
    return SecureStore.getItemAsync(TOKEN_KEY);
  }

  // Get refresh token
  static async getRefreshToken(): Promise<string | null> {
    return SecureStore.getItemAsync(REFRESH_TOKEN_KEY);
  }

  // Get both tokens
  static async getTokens(): Promise<AuthTokens | null> {
    const [accessToken, refreshToken] = await Promise.all([
      SecureStore.getItemAsync(TOKEN_KEY),
      SecureStore.getItemAsync(REFRESH_TOKEN_KEY),
    ]);

    if (!accessToken || !refreshToken) {
      return null;
    }

    return { accessToken, refreshToken };
  }

  // Clear tokens on logout
  static async clearTokens(): Promise<void> {
    await Promise.all([
      SecureStore.deleteItemAsync(TOKEN_KEY),
      SecureStore.deleteItemAsync(REFRESH_TOKEN_KEY),
    ]);
  }

  // Check if user is logged in
  static async isAuthenticated(): Promise<boolean> {
    const token = await SecureStore.getItemAsync(TOKEN_KEY);
    return token !== null;
  }
}

// Usage in auth context
async function login(email: string, password: string) {
  const response = await api.post('/auth/login', { email, password });
  const { accessToken, refreshToken } = response.data;
  
  await TokenStorage.saveTokens({ accessToken, refreshToken });
  
  // Update auth state
  setIsAuthenticated(true);
}

async function logout() {
  await TokenStorage.clearTokens();
  setIsAuthenticated(false);
}

✅ When to Use SecureStore

  • Authentication tokens (access tokens, refresh tokens)
  • API keys that must remain secret
  • User credentials (if storing locally is necessary)
  • Encryption keys for other data
  • Any small piece of sensitive information

MMKV: High-Performance Storage

MMKV is a key-value storage library developed by WeChat. It's significantly faster than AsyncStorage—up to 30x faster for some operations. If your app does frequent storage operations, MMKV can make a noticeable difference.

Why MMKV is Faster

🚀 MMKV Performance Advantages

  • Memory-mapped files: Uses mmap for direct memory access instead of file I/O
  • Synchronous API: No async overhead—reads/writes complete immediately
  • Protobuf encoding: More efficient than JSON serialization
  • Incremental updates: Only writes changes, not entire file
  • Native implementation: Written in C++ for maximum speed

Installation

# For Expo with development build
npx expo install react-native-mmkv

# Requires a development build (won't work in Expo Go)
npx expo prebuild
npx expo run:ios  # or run:android

⚠️ Requires Development Build

MMKV includes native code and won't work in Expo Go. You'll need to create a development build. This is usually fine for production apps but adds complexity during development.

Basic Operations

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

// Create a storage instance
const storage = new MMKV();

// Or with custom ID (for multiple instances)
const userStorage = new MMKV({ id: 'user-storage' });

// Or with encryption
const secureStorage = new MMKV({
  id: 'secure-storage',
  encryptionKey: 'your-encryption-key',
});


// ============= STORING DATA =============

// Strings - synchronous!
storage.set('username', 'john_doe');

// Numbers
storage.set('loginCount', 42);

// Booleans
storage.set('isPremium', true);

// Objects (must stringify)
const user = { id: 1, name: 'John' };
storage.set('user', JSON.stringify(user));


// ============= RETRIEVING DATA =============

// Strings
const username = storage.getString('username');  // 'john_doe' or undefined

// Numbers
const count = storage.getNumber('loginCount');  // 42 or undefined

// Booleans
const isPremium = storage.getBoolean('isPremium');  // true or undefined

// Objects
const userJson = storage.getString('user');
const user = userJson ? JSON.parse(userJson) : null;


// ============= OTHER OPERATIONS =============

// Check if key exists
const hasUser = storage.contains('user');

// Delete a key
storage.delete('username');

// Get all keys
const allKeys = storage.getAllKeys();  // ['user', 'loginCount', ...]

// Clear everything
storage.clearAll();


// ============= LISTENERS =============

// Listen for changes (great for syncing UI)
const listener = storage.addOnValueChangedListener((key) => {
  console.log(`Value changed for key: ${key}`);
  const newValue = storage.getString(key);
  // Update UI...
});

// Remove listener when done
listener.remove();

Creating a React Hook for MMKV

import { useCallback, useSyncExternalStore } from 'react';
import { MMKV } from 'react-native-mmkv';

const storage = new MMKV();

// Hook for string values
export function useMMKVString(key: string) {
  const subscribe = useCallback(
    (callback: () => void) => {
      const listener = storage.addOnValueChangedListener((changedKey) => {
        if (changedKey === key) callback();
      });
      return () => listener.remove();
    },
    [key]
  );

  const getSnapshot = useCallback(() => {
    return storage.getString(key);
  }, [key]);

  const value = useSyncExternalStore(subscribe, getSnapshot);

  const setValue = useCallback(
    (newValue: string | undefined) => {
      if (newValue === undefined) {
        storage.delete(key);
      } else {
        storage.set(key, newValue);
      }
    },
    [key]
  );

  return [value, setValue] as const;
}

// Hook for objects
export function useMMKVObject<T>(key: string) {
  const [json, setJson] = useMMKVString(key);

  const value = json ? (JSON.parse(json) as T) : undefined;

  const setValue = useCallback(
    (newValue: T | undefined) => {
      setJson(newValue === undefined ? undefined : JSON.stringify(newValue));
    },
    [setJson]
  );

  return [value, setValue] as const;
}

// Usage
function UserProfile() {
  const [user, setUser] = useMMKVObject<User>('user');

  return (
    <View>
      <Text>{user?.name ?? 'Not logged in'}</Text>
      <Button
        title="Update Name"
        onPress={() => setUser({ ...user!, name: 'New Name' })}
      />
    </View>
  );
}

✅ When to Use MMKV

  • Frequently accessed data (read/write many times per session)
  • App state that needs to persist (e.g., Redux/Zustand persistence)
  • When you need synchronous access (app initialization)
  • Large amounts of cached data
  • Performance-critical applications

Comparing Storage Options

Here's a comprehensive comparison to help you choose:

Feature AsyncStorage SecureStore MMKV
Performance ⭐⭐ Slow ⭐⭐ Slow ⭐⭐⭐⭐⭐ Very Fast
Encryption ❌ None ✅ Hardware-backed ✅ Optional
Size Limit ~6MB (configurable) 2KB per value No practical limit
Sync/Async Async only Async only Sync
Expo Go ✅ Works ✅ Works ❌ Dev build only
Data Types Strings only Strings only String, Number, Boolean
Best For General storage Sensitive data High-performance needs
flowchart TD
    A[Need to store data locally?] --> B{Is it sensitive?}
    B -->|Yes| C{Size > 2KB?}
    B -->|No| D{Need high performance?}
    
    C -->|Yes| E[MMKV with encryption]
    C -->|No| F[SecureStore]
    
    D -->|Yes| G{Using Expo Go?}
    D -->|No| H[AsyncStorage]
    
    G -->|Yes| H
    G -->|No| I[MMKV]
    
    style F fill:#fce4ec,stroke:#E91E63
    style E fill:#fff3e0,stroke:#FF9800
    style H fill:#e3f2fd,stroke:#2196F3
    style I fill:#fff3e0,stroke:#FF9800
                

Building Storage Abstractions

In a real app, you might want to use different storage backends for different data types. A clean abstraction layer lets you swap implementations without changing your app code.

Unified Storage Interface

// storage/types.ts
export interface Storage {
  get<T>(key: string): Promise<T | null>;
  set<T>(key: string, value: T): Promise<void>;
  remove(key: string): Promise<void>;
  clear(): Promise<void>;
}

// storage/asyncStorage.ts
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { Storage } from './types';

export const asyncStorage: Storage = {
  async get<T>(key: string): Promise<T | null> {
    const value = await AsyncStorage.getItem(key);
    return value ? JSON.parse(value) : null;
  },

  async set<T>(key: string, value: T): Promise<void> {
    await AsyncStorage.setItem(key, JSON.stringify(value));
  },

  async remove(key: string): Promise<void> {
    await AsyncStorage.removeItem(key);
  },

  async clear(): Promise<void> {
    await AsyncStorage.clear();
  },
};

// storage/secureStorage.ts
import * as SecureStore from 'expo-secure-store';
import type { Storage } from './types';

export const secureStorage: Storage = {
  async get<T>(key: string): Promise<T | null> {
    const value = await SecureStore.getItemAsync(key);
    return value ? JSON.parse(value) : null;
  },

  async set<T>(key: string, value: T): Promise<void> {
    await SecureStore.setItemAsync(key, JSON.stringify(value));
  },

  async remove(key: string): Promise<void> {
    await SecureStore.deleteItemAsync(key);
  },

  async clear(): Promise<void> {
    // SecureStore doesn't have clear, so we'd need to track keys
    console.warn('SecureStore clear not implemented');
  },
};

// storage/mmkvStorage.ts (for dev builds)
import { MMKV } from 'react-native-mmkv';
import type { Storage } from './types';

const mmkv = new MMKV();

export const mmkvStorage: Storage = {
  async get<T>(key: string): Promise<T | null> {
    const value = mmkv.getString(key);
    return value ? JSON.parse(value) : null;
  },

  async set<T>(key: string, value: T): Promise<void> {
    mmkv.set(key, JSON.stringify(value));
  },

  async remove(key: string): Promise<void> {
    mmkv.delete(key);
  },

  async clear(): Promise<void> {
    mmkv.clearAll();
  },
};

Domain-Specific Storage Classes

Build higher-level storage classes for specific domains:

// storage/userStorage.ts
import { secureStorage } from './secureStorage';
import { asyncStorage } from './asyncStorage';

interface UserTokens {
  accessToken: string;
  refreshToken: string;
  expiresAt: number;
}

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

interface UserProfile {
  id: string;
  name: string;
  email: string;
  avatarUrl?: string;
}

class UserStorage {
  private static readonly KEYS = {
    TOKENS: 'user_tokens',
    PREFERENCES: 'user_preferences',
    PROFILE: 'user_profile',
    ONBOARDING_COMPLETE: 'onboarding_complete',
  };

  // Tokens - use secure storage
  static async getTokens(): Promise<UserTokens | null> {
    return secureStorage.get<UserTokens>(this.KEYS.TOKENS);
  }

  static async setTokens(tokens: UserTokens): Promise<void> {
    return secureStorage.set(this.KEYS.TOKENS, tokens);
  }

  static async clearTokens(): Promise<void> {
    return secureStorage.remove(this.KEYS.TOKENS);
  }

  static async isTokenExpired(): Promise<boolean> {
    const tokens = await this.getTokens();
    if (!tokens) return true;
    return Date.now() >= tokens.expiresAt;
  }

  // Preferences - use async storage (not sensitive)
  static async getPreferences(): Promise<UserPreferences> {
    const prefs = await asyncStorage.get<UserPreferences>(this.KEYS.PREFERENCES);
    return prefs ?? {
      theme: 'system',
      language: 'en',
      notifications: true,
    };
  }

  static async setPreferences(prefs: Partial<UserPreferences>): Promise<void> {
    const current = await this.getPreferences();
    return asyncStorage.set(this.KEYS.PREFERENCES, { ...current, ...prefs });
  }

  // Profile - use async storage
  static async getProfile(): Promise<UserProfile | null> {
    return asyncStorage.get<UserProfile>(this.KEYS.PROFILE);
  }

  static async setProfile(profile: UserProfile): Promise<void> {
    return asyncStorage.set(this.KEYS.PROFILE, profile);
  }

  // Onboarding state
  static async isOnboardingComplete(): Promise<boolean> {
    const complete = await asyncStorage.get<boolean>(this.KEYS.ONBOARDING_COMPLETE);
    return complete ?? false;
  }

  static async setOnboardingComplete(): Promise<void> {
    return asyncStorage.set(this.KEYS.ONBOARDING_COMPLETE, true);
  }

  // Clear all user data (logout)
  static async clearAll(): Promise<void> {
    await Promise.all([
      secureStorage.remove(this.KEYS.TOKENS),
      asyncStorage.remove(this.KEYS.PROFILE),
      // Keep preferences on logout
    ]);
  }
}

export default UserStorage;

// Usage in components
async function handleLogout() {
  await UserStorage.clearAll();
  router.replace('/login');
}

async function checkAuth() {
  const tokens = await UserStorage.getTokens();
  if (!tokens) return false;
  
  const expired = await UserStorage.isTokenExpired();
  if (expired) {
    // Try to refresh...
  }
  
  return true;
}

Storage with Migrations

When your app evolves, stored data structures might need to change. Here's a pattern for handling migrations:

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

const STORAGE_VERSION_KEY = 'storage_version';
const CURRENT_VERSION = 3;

interface Migration {
  version: number;
  migrate: () => Promise<void>;
}

const migrations: Migration[] = [
  {
    version: 1,
    migrate: async () => {
      // v1: Initial structure - nothing to do
    },
  },
  {
    version: 2,
    migrate: async () => {
      // v2: Rename 'user' key to 'user_profile'
      const oldUser = await AsyncStorage.getItem('user');
      if (oldUser) {
        await AsyncStorage.setItem('user_profile', oldUser);
        await AsyncStorage.removeItem('user');
      }
    },
  },
  {
    version: 3,
    migrate: async () => {
      // v3: Add 'notifications' to preferences
      const prefsJson = await AsyncStorage.getItem('user_preferences');
      if (prefsJson) {
        const prefs = JSON.parse(prefsJson);
        if (prefs.notifications === undefined) {
          prefs.notifications = true;  // Default value
          await AsyncStorage.setItem('user_preferences', JSON.stringify(prefs));
        }
      }
    },
  },
];

export async function runMigrations(): Promise<void> {
  const versionStr = await AsyncStorage.getItem(STORAGE_VERSION_KEY);
  const currentVersion = versionStr ? parseInt(versionStr, 10) : 0;

  if (currentVersion >= CURRENT_VERSION) {
    return; // Already up to date
  }

  console.log(`Running storage migrations from v${currentVersion} to v${CURRENT_VERSION}`);

  // Run all migrations newer than current version
  for (const migration of migrations) {
    if (migration.version > currentVersion) {
      console.log(`Running migration v${migration.version}`);
      try {
        await migration.migrate();
      } catch (error) {
        console.error(`Migration v${migration.version} failed:`, error);
        throw error;
      }
    }
  }

  // Update version
  await AsyncStorage.setItem(STORAGE_VERSION_KEY, CURRENT_VERSION.toString());
  console.log('Migrations complete');
}

// Run at app startup (in _layout.tsx or App.tsx)
// useEffect(() => {
//   runMigrations().catch(console.error);
// }, []);

Persisting React Query Cache

One powerful use of local storage is persisting your React Query cache. This means your app can show cached data immediately on startup, even before network requests complete.

Installation

npx expo install @tanstack/query-async-storage-persister @tanstack/react-query-persist-client

Setup with AsyncStorage

// app/_layout.tsx
import { Stack } from 'expo-router';
import { QueryClient } from '@tanstack/react-query';
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';
import AsyncStorage from '@react-native-async-storage/async-storage';

// Create persister
const asyncStoragePersister = createAsyncStoragePersister({
  storage: AsyncStorage,
  key: 'REACT_QUERY_CACHE',
});

// Create query client with longer cache time for persistence
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      gcTime: 1000 * 60 * 60 * 24, // 24 hours
      staleTime: 1000 * 60 * 5,    // 5 minutes
    },
  },
});

export default function RootLayout() {
  return (
    <PersistQueryClientProvider
      client={queryClient}
      persistOptions={{
        persister: asyncStoragePersister,
        maxAge: 1000 * 60 * 60 * 24, // 24 hours
        dehydrateOptions: {
          shouldDehydrateQuery: (query) => {
            // Only persist successful queries
            return query.state.status === 'success';
          },
        },
      }}
    >
      <Stack />
    </PersistQueryClientProvider>
  );
}

Setup with MMKV (Higher Performance)

// storage/queryPersister.ts
import { MMKV } from 'react-native-mmkv';
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';

const queryStorage = new MMKV({ id: 'query-cache' });

// MMKV-compatible storage interface
const mmkvStorageInterface = {
  getItem: (key: string) => {
    const value = queryStorage.getString(key);
    return value ?? null;
  },
  setItem: (key: string, value: string) => {
    queryStorage.set(key, value);
  },
  removeItem: (key: string) => {
    queryStorage.delete(key);
  },
};

export const mmkvPersister = createSyncStoragePersister({
  storage: mmkvStorageInterface,
  key: 'REACT_QUERY_CACHE',
});

// app/_layout.tsx
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { mmkvPersister } from '../storage/queryPersister';

export default function RootLayout() {
  return (
    <PersistQueryClientProvider
      client={queryClient}
      persistOptions={{
        persister: mmkvPersister,
        maxAge: 1000 * 60 * 60 * 24,
      }}
    >
      <Stack />
    </PersistQueryClientProvider>
  );
}

✅ Benefits of Cache Persistence

  • Instant app startup: Show cached data immediately
  • Offline support: App works (read-only) without network
  • Reduced API calls: Fresh-enough data doesn't need refetching
  • Better UX: No loading spinners for returning users

⚠️ What NOT to Persist

Some queries shouldn't be persisted:

  • Real-time data (stock prices, live scores)
  • User-specific data that shouldn't survive logout
  • Large datasets that would bloat storage

Use shouldDehydrateQuery to filter what gets cached.

Hands-On Exercises

Exercise 1: Theme Persistence with AsyncStorage

Create a theme toggle that persists the user's preference using AsyncStorage.

Requirements:

  • Create a custom useTheme hook
  • Load saved theme on app startup
  • Save theme changes to AsyncStorage
  • Show a loading state while initial theme is loading
  • Apply the theme to the UI
Show Hint

Use useState for the theme value and isLoading. In a useEffect, load the saved theme from AsyncStorage. Create a setTheme function that updates both state and storage.

Show Solution
// hooks/useTheme.ts
import { useState, useEffect, useCallback, createContext, useContext } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';

type Theme = 'light' | 'dark';

interface ThemeContext {
  theme: Theme;
  setTheme: (theme: Theme) => Promise<void>;
  toggleTheme: () => Promise<void>;
  isLoading: boolean;
}

const ThemeContext = createContext<ThemeContext | null>(null);

const THEME_KEY = 'app_theme';

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setThemeState] = useState<Theme>('light');
  const [isLoading, setIsLoading] = useState(true);

  // Load theme on mount
  useEffect(() => {
    const loadTheme = async () => {
      try {
        const savedTheme = await AsyncStorage.getItem(THEME_KEY);
        if (savedTheme === 'light' || savedTheme === 'dark') {
          setThemeState(savedTheme);
        }
      } catch (error) {
        console.error('Failed to load theme:', error);
      } finally {
        setIsLoading(false);
      }
    };

    loadTheme();
  }, []);

  const setTheme = useCallback(async (newTheme: Theme) => {
    try {
      setThemeState(newTheme);
      await AsyncStorage.setItem(THEME_KEY, newTheme);
    } catch (error) {
      console.error('Failed to save theme:', error);
    }
  }, []);

  const toggleTheme = useCallback(async () => {
    const newTheme = theme === 'light' ? 'dark' : 'light';
    await setTheme(newTheme);
  }, [theme, setTheme]);

  return (
    <ThemeContext.Provider value={{ theme, setTheme, toggleTheme, isLoading }}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error('useTheme must be used within ThemeProvider');
  }
  return context;
}

// Usage in component
function SettingsScreen() {
  const { theme, toggleTheme, isLoading } = useTheme();

  if (isLoading) {
    return <ActivityIndicator />;
  }

  return (
    <View style={[styles.container, theme === 'dark' && styles.darkContainer]}>
      <Text style={theme === 'dark' ? styles.darkText : styles.lightText}>
        Current theme: {theme}
      </Text>
      <Switch
        value={theme === 'dark'}
        onValueChange={toggleTheme}
      />
    </View>
  );
}

Exercise 2: Secure Token Storage

Implement a secure authentication token manager using SecureStore.

Requirements:

  • Create a TokenManager class with save, get, clear methods
  • Store both access and refresh tokens
  • Include token expiration handling
  • Create a hook that provides the current auth state
Show Hint

Store tokens as a JSON object with accessToken, refreshToken, and expiresAt fields. Create a method to check if the token is expired by comparing expiresAt with Date.now().

Show Solution
// auth/tokenManager.ts
import * as SecureStore from 'expo-secure-store';

const TOKEN_KEY = 'auth_tokens';

interface Tokens {
  accessToken: string;
  refreshToken: string;
  expiresAt: number; // Unix timestamp
}

class TokenManager {
  static async save(
    accessToken: string,
    refreshToken: string,
    expiresInSeconds: number
  ): Promise<void> {
    const tokens: Tokens = {
      accessToken,
      refreshToken,
      expiresAt: Date.now() + expiresInSeconds * 1000,
    };
    await SecureStore.setItemAsync(TOKEN_KEY, JSON.stringify(tokens));
  }

  static async get(): Promise<Tokens | null> {
    const json = await SecureStore.getItemAsync(TOKEN_KEY);
    return json ? JSON.parse(json) : null;
  }

  static async getAccessToken(): Promise<string | null> {
    const tokens = await this.get();
    return tokens?.accessToken ?? null;
  }

  static async getRefreshToken(): Promise<string | null> {
    const tokens = await this.get();
    return tokens?.refreshToken ?? null;
  }

  static async isExpired(): Promise<boolean> {
    const tokens = await this.get();
    if (!tokens) return true;
    // Add 60 second buffer
    return Date.now() >= tokens.expiresAt - 60000;
  }

  static async clear(): Promise<void> {
    await SecureStore.deleteItemAsync(TOKEN_KEY);
  }
}

// hooks/useAuth.ts
import { useState, useEffect, useCallback } from 'react';

interface AuthState {
  isAuthenticated: boolean;
  isLoading: boolean;
  accessToken: string | null;
}

export function useAuth() {
  const [state, setState] = useState<AuthState>({
    isAuthenticated: false,
    isLoading: true,
    accessToken: null,
  });

  // Check auth on mount
  useEffect(() => {
    const checkAuth = async () => {
      try {
        const tokens = await TokenManager.get();
        const expired = await TokenManager.isExpired();
        
        setState({
          isAuthenticated: !!tokens && !expired,
          isLoading: false,
          accessToken: !expired ? tokens?.accessToken ?? null : null,
        });
      } catch (error) {
        setState({ isAuthenticated: false, isLoading: false, accessToken: null });
      }
    };

    checkAuth();
  }, []);

  const login = useCallback(async (
    accessToken: string,
    refreshToken: string,
    expiresIn: number
  ) => {
    await TokenManager.save(accessToken, refreshToken, expiresIn);
    setState({
      isAuthenticated: true,
      isLoading: false,
      accessToken,
    });
  }, []);

  const logout = useCallback(async () => {
    await TokenManager.clear();
    setState({
      isAuthenticated: false,
      isLoading: false,
      accessToken: null,
    });
  }, []);

  return { ...state, login, logout };
}

Exercise 3: Draft Message Auto-Save

Build a text input that auto-saves drafts to storage as the user types.

Requirements:

  • Save the draft after 500ms of no typing (debounce)
  • Load any saved draft when the component mounts
  • Clear the draft when the message is sent
  • Show a "Draft saved" indicator
Show Hint

Use setTimeout to debounce saves. Clear the previous timeout when the user types again. Store the draft keyed by conversation/screen ID if needed.

Show Solution
import { useState, useEffect, useRef } from 'react';
import { View, TextInput, Pressable, Text, StyleSheet } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';

interface MessageComposerProps {
  conversationId: string;
  onSend: (message: string) => void;
}

export function MessageComposer({ conversationId, onSend }: MessageComposerProps) {
  const [text, setText] = useState('');
  const [isSaved, setIsSaved] = useState(false);
  const [isLoading, setIsLoading] = useState(true);
  const saveTimeoutRef = useRef<NodeJS.Timeout>();

  const draftKey = `draft_${conversationId}`;

  // Load draft on mount
  useEffect(() => {
    const loadDraft = async () => {
      try {
        const draft = await AsyncStorage.getItem(draftKey);
        if (draft) {
          setText(draft);
        }
      } catch (error) {
        console.error('Failed to load draft:', error);
      } finally {
        setIsLoading(false);
      }
    };

    loadDraft();
  }, [draftKey]);

  // Debounced save
  useEffect(() => {
    if (isLoading) return; // Don't save during initial load

    setIsSaved(false);

    // Clear previous timeout
    if (saveTimeoutRef.current) {
      clearTimeout(saveTimeoutRef.current);
    }

    // Save after 500ms of no typing
    saveTimeoutRef.current = setTimeout(async () => {
      try {
        if (text.trim()) {
          await AsyncStorage.setItem(draftKey, text);
          setIsSaved(true);
        } else {
          await AsyncStorage.removeItem(draftKey);
        }
      } catch (error) {
        console.error('Failed to save draft:', error);
      }
    }, 500);

    return () => {
      if (saveTimeoutRef.current) {
        clearTimeout(saveTimeoutRef.current);
      }
    };
  }, [text, draftKey, isLoading]);

  const handleSend = async () => {
    if (!text.trim()) return;

    onSend(text);
    setText('');

    // Clear the draft
    try {
      await AsyncStorage.removeItem(draftKey);
    } catch (error) {
      console.error('Failed to clear draft:', error);
    }
  };

  return (
    <View style={styles.container}>
      <View style={styles.inputContainer}>
        <TextInput
          style={styles.input}
          value={text}
          onChangeText={setText}
          placeholder="Type a message..."
          multiline
        />
        {isSaved && (
          <Text style={styles.savedIndicator}>Draft saved</Text>
        )}
      </View>
      <Pressable
        style={[styles.sendButton, !text.trim() && styles.sendButtonDisabled]}
        onPress={handleSend}
        disabled={!text.trim()}
      >
        <Text style={styles.sendButtonText}>Send</Text>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flexDirection: 'row',
    padding: 12,
    borderTopWidth: 1,
    borderTopColor: '#eee',
    alignItems: 'flex-end',
  },
  inputContainer: {
    flex: 1,
    marginRight: 12,
  },
  input: {
    backgroundColor: '#f5f5f5',
    borderRadius: 20,
    paddingHorizontal: 16,
    paddingVertical: 10,
    maxHeight: 100,
    fontSize: 16,
  },
  savedIndicator: {
    fontSize: 10,
    color: '#999',
    marginTop: 4,
    marginLeft: 16,
  },
  sendButton: {
    backgroundColor: '#667eea',
    paddingHorizontal: 20,
    paddingVertical: 10,
    borderRadius: 20,
  },
  sendButtonDisabled: {
    backgroundColor: '#ccc',
  },
  sendButtonText: {
    color: 'white',
    fontWeight: '600',
  },
});

Summary

Local storage is essential for building mobile apps that feel fast, work offline, and remember user preferences. Choosing the right storage solution depends on your data's sensitivity, size, and access patterns.

🎯 Key Takeaways

  • AsyncStorage is the standard choice for general-purpose storage—simple, reliable, and works everywhere
  • SecureStore encrypts data using device hardware—essential for tokens, passwords, and secrets
  • MMKV provides synchronous, high-performance storage—great for frequently accessed data
  • Build abstractions that combine different storage backends based on data sensitivity
  • Handle migrations when your data structures evolve
  • Persist React Query cache for instant app startup and offline support
  • Always stringify objects before storing and parse when retrieving
  • Handle errors—storage operations can fail

In the next lesson, we'll explore state management at scale—when local component state and React Query aren't enough, and you need global state management solutions like Zustand or Redux Toolkit.