DailyDevDiet

logo - dailydevdiet

Learn. Build. Innovate. Elevate your coding skills with dailydevdiet!

Chapter 16: Local Storage and Data Persistence

Data Persistence

Introduction

Data persistence is crucial for creating meaningful mobile applications. Users expect their data to be saved between app sessions, whether it’s user preferences, cached data, or application state. React Native provides several options for local storage, each suited for different use cases and data types.

In this chapter, we’ll explore various storage solutions available in React Native, from simple key-value storage to complex database operations. We’ll cover when to use each approach and provide practical examples to help you implement data persistence in your applications.

Storage Options Overview

React Native offers several storage mechanisms:

  1. AsyncStorage – Simple key-value storage
  2. React Native MMKV – Fast key-value storage
  3. SQLite – Relational database storage
  4. Realm – Object database
  5. SecureStore – Secure storage for sensitive data
  6. File System – Direct file operations

AsyncStorage

AsyncStorage is React Native’s built-in asynchronous storage system. It’s perfect for storing simple key-value pairs like user preferences, settings, or small amounts of data.

Installation

For React Native 0.60+, AsyncStorage is a separate package:

npm install @react-native-async-storage/async-storage

For iOS, you’ll need to run:

cd ios && pod install

Basic AsyncStorage Operations

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

// Store data
const storeData = async (key, value) => {
  try {
    await AsyncStorage.setItem(key, value);
    console.log('Data stored successfully');
  } catch (error) {
    console.error('Error storing data:', error);
  }
};

// Retrieve data
const getData = async (key) => {
  try {
    const value = await AsyncStorage.getItem(key);
    if (value !== null) {
      return value;
    }
  } catch (error) {
    console.error('Error retrieving data:', error);
  }
};

// Remove data
const removeData = async (key) => {
  try {
    await AsyncStorage.removeItem(key);
    console.log('Data removed successfully');
  } catch (error) {
    console.error('Error removing data:', error);
  }
};

// Clear all data
const clearAllData = async () => {
  try {
    await AsyncStorage.clear();
    console.log('All data cleared');
  } catch (error) {
    console.error('Error clearing data:', error);
  }
};

Working with Objects

AsyncStorage only stores strings, so you need to serialize objects:
import AsyncStorage from '@react-native-async-storage/async-storage';

// Store object
const storeObject = async (key, object) => {
  try {
    const jsonValue = JSON.stringify(object);
    await AsyncStorage.setItem(key, jsonValue);
  } catch (error) {
    console.error('Error storing object:', error);
  }
};

// Retrieve object
const getObject = async (key) => {
  try {
    const jsonValue = await AsyncStorage.getItem(key);
    return jsonValue != null ? JSON.parse(jsonValue) : null;
  } catch (error) {
    console.error('Error retrieving object:', error);
    return null;
  }
};

// Example usage
const user = {
  id: 1,
  name: 'John Doe',
  email: 'john@example.com',
  preferences: {
    theme: 'dark',
    notifications: true
  }
};

// Store user object
await storeObject('user', user);

// Retrieve user object
const retrievedUser = await getObject('user');
console.log(retrievedUser);

AsyncStorage Hook

Create a custom hook for easier AsyncStorage usage:

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

const useAsyncStorage = (key, initialValue = null) => {
  const [storedValue, setStoredValue] = useState(initialValue);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const loadStoredValue = async () => {
      try {
        const item = await AsyncStorage.getItem(key);
        if (item) {
          setStoredValue(JSON.parse(item));
        }
      } catch (error) {
        console.error('Error loading stored value:', error);
      } finally {
        setLoading(false);
      }
    };

    loadStoredValue();
  }, [key]);

  const setValue = async (value) => {
    try {
      setStoredValue(value);
      await AsyncStorage.setItem(key, JSON.stringify(value));
    } catch (error) {
      console.error('Error setting value:', error);
    }
  };

  const removeValue = async () => {
    try {
      setStoredValue(initialValue);
      await AsyncStorage.removeItem(key);
    } catch (error) {
      console.error('Error removing value:', error);
    }
  };

  return [storedValue, setValue, removeValue, loading];
};

// Usage example
const SettingsScreen = () => {
  const [settings, setSettings, removeSettings, loading] = useAsyncStorage('settings', {
    theme: 'light',
    notifications: true
  });

  if (loading) {
    return <Text>Loading...</Text>;
  }

  const toggleTheme = () => {
    setSettings({
      ...settings,
      theme: settings.theme === 'light' ? 'dark' : 'light'
    });
  };

  return (
    <View>
      <Text>Current theme: {settings.theme}</Text>
      <Button title="Toggle Theme" onPress={toggleTheme} />
    </View>
  );
};

React Native MMKV

MMKV is a high-performance alternative to AsyncStorage, offering better performance and additional features.

Installation

npm install react-native-mmkv

For iOS:
cd ios && pod install

Basic MMKV Usage

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

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

// Store data
storage.set('username', 'john_doe');
storage.set('age', 25);
storage.set('isActive', true);

// Retrieve data
const username = storage.getString('username');
const age = storage.getNumber('age');
const isActive = storage.getBoolean('isActive');

// Store objects
const user = { id: 1, name: 'John', email: 'john@example.com' };
storage.set('user', JSON.stringify(user));

// Retrieve objects
const storedUser = JSON.parse(storage.getString('user') || '{}');

// Delete data
storage.delete('username');

// Check if key exists
const hasUsername = storage.contains('username');

// Get all keys
const allKeys = storage.getAllKeys();

// Clear all data
storage.clearAll();

MMKV with Encryption

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

// Create encrypted storage
const encryptedStorage = new MMKV({
  id: 'secure-storage',
  encryptionKey: 'your-encryption-key-here'
});

// Use same methods as regular MMKV
encryptedStorage.set('sensitiveData', 'secret information');
const sensitiveData = encryptedStorage.getString('sensitiveData');

SQLite Database

For complex data structures and relationships, SQLite is the go-to solution.

Installation

npm install react-native-sqlite-storage

Basic SQLite Setup

import SQLite from 'react-native-sqlite-storage';

// Enable debugging
SQLite.DEBUG(true);
SQLite.enablePromise(true);

class DatabaseManager {
  constructor() {
    this.db = null;
  }

  async openDatabase() {
    try {
      this.db = await SQLite.openDatabase({
        name: 'MyDatabase.db',
        location: 'default',
      });
      console.log('Database opened successfully');
      await this.createTables();
    } catch (error) {
      console.error('Error opening database:', error);
    }
  }

  async createTables() {
    try {
      await this.db.executeSql(`
        CREATE TABLE IF NOT EXISTS users (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          name TEXT NOT NULL,
          email TEXT UNIQUE NOT NULL,
          age INTEGER,
          created_at DATETIME DEFAULT CURRENT_TIMESTAMP
        )
      `);
     
      await this.db.executeSql(`
        CREATE TABLE IF NOT EXISTS posts (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          title TEXT NOT NULL,
          content TEXT,
          user_id INTEGER,
          created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
          FOREIGN KEY (user_id) REFERENCES users (id)
        )
      `);
     
      console.log('Tables created successfully');
    } catch (error) {
      console.error('Error creating tables:', error);
    }
  }

  async insertUser(name, email, age) {
    try {
      const result = await this.db.executeSql(
        'INSERT INTO users (name, email, age) VALUES (?, ?, ?)',
        [name, email, age]
      );
      return result[0].insertId;
    } catch (error) {
      console.error('Error inserting user:', error);
      throw error;
    }
  }

  async getUsers() {
    try {
      const result = await this.db.executeSql('SELECT * FROM users');
      const users = [];
      for (let i = 0; i < result[0].rows.length; i++) {
        users.push(result[0].rows.item(i));
      }
      return users;
    } catch (error) {
      console.error('Error fetching users:', error);
      return [];
    }
  }

  async updateUser(id, name, email, age) {
    try {
      await this.db.executeSql(
        'UPDATE users SET name = ?, email = ?, age = ? WHERE id = ?',
        [name, email, age, id]
      );
    } catch (error) {
      console.error('Error updating user:', error);
      throw error;
    }
  }

  async deleteUser(id) {
    try {
      await this.db.executeSql('DELETE FROM users WHERE id = ?', [id]);
    } catch (error) {
      console.error('Error deleting user:', error);
      throw error;
    }
  }

  async getUserWithPosts(userId) {
    try {
      const result = await this.db.executeSql(`
        SELECT
          u.id as user_id,
          u.name,
          u.email,
          u.age,
          p.id as post_id,
          p.title,
          p.content,
          p.created_at as post_created_at
        FROM users u
        LEFT JOIN posts p ON u.id = p.user_id
        WHERE u.id = ?
      `, [userId]);
     
      const rows = result[0].rows;
      if (rows.length === 0) return null;
     
      const user = {
        id: rows.item(0).user_id,
        name: rows.item(0).name,
        email: rows.item(0).email,
        age: rows.item(0).age,
        posts: []
      };
     
      for (let i = 0; i < rows.length; i++) {
        const row = rows.item(i);
        if (row.post_id) {
          user.posts.push({
            id: row.post_id,
            title: row.title,
            content: row.content,
            created_at: row.post_created_at
          });
        }
      }
     
      return user;
    } catch (error) {
      console.error('Error fetching user with posts:', error);
      return null;
    }
  }

  async closeDatabase() {
    if (this.db) {
      await this.db.close();
    }
  }
}

// Usage
const dbManager = new DatabaseManager();

// In your app initialization
useEffect(() => {
  const initDatabase = async () => {
    await dbManager.openDatabase();
  };
 
  initDatabase();
 
  return () => {
    dbManager.closeDatabase();
  };
}, []);

Secure Storage

For sensitive data like authentication tokens, use secure storage solutions.

Using Keychain Services (iOS) and Keystore (Android)

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

// Store credentials
const storeCredentials = async (username, password) => {
  try {
    await Keychain.setInternetCredentials('myapp', username, password);
    console.log('Credentials stored successfully');
  } catch (error) {
    console.error('Error storing credentials:', error);
  }
};

// Retrieve credentials
const getCredentials = async () => {
  try {
    const credentials = await Keychain.getInternetCredentials('myapp');
    if (credentials) {
      return {
        username: credentials.username,
        password: credentials.password
      };
    }
  } catch (error) {
    console.error('Error retrieving credentials:', error);
  }
  return null;
};

// Remove credentials
const removeCredentials = async () => {
  try {
    await Keychain.resetInternetCredentials('myapp');
    console.log('Credentials removed successfully');
  } catch (error) {
    console.error('Error removing credentials:', error);
  }
};

// Store generic data securely
const storeSecureData = async (key, data) => {
  try {
    await Keychain.setInternetCredentials(key, 'data', JSON.stringify(data));
  } catch (error) {
    console.error('Error storing secure data:', error);
  }
};

const getSecureData = async (key) => {
  try {
    const credentials = await Keychain.getInternetCredentials(key);
    if (credentials) {
      return JSON.parse(credentials.password);
    }
  } catch (error) {
    console.error('Error retrieving secure data:', error);
  }
  return null;
};

File System Operations

For direct file operations, use the React Native File System library.

Installation

npm install react-native-fs

Basic File Operations

import RNFS from 'react-native-fs';

// Get document directory path
const documentsPath = RNFS.DocumentDirectoryPath;

// Write file
const writeFile = async (fileName, content) => {
  try {
    const filePath = `${documentsPath}/${fileName}`;
    await RNFS.writeFile(filePath, content, 'utf8');
    console.log('File written successfully');
  } catch (error) {
    console.error('Error writing file:', error);
  }
};

// Read file
const readFile = async (fileName) => {
  try {
    const filePath = `${documentsPath}/${fileName}`;
    const content = await RNFS.readFile(filePath, 'utf8');
    return content;
  } catch (error) {
    console.error('Error reading file:', error);
    return null;
  }
};

// Check if file exists
const fileExists = async (fileName) => {
  try {
    const filePath = `${documentsPath}/${fileName}`;
    return await RNFS.exists(filePath);
  } catch (error) {
    console.error('Error checking file existence:', error);
    return false;
  }
};

// Delete file
const deleteFile = async (fileName) => {
  try {
    const filePath = `${documentsPath}/${fileName}`;
    await RNFS.unlink(filePath);
    console.log('File deleted successfully');
  } catch (error) {
    console.error('Error deleting file:', error);
  }
};

// List files in directory
const listFiles = async () => {
  try {
    const files = await RNFS.readDir(documentsPath);
    return files.map(file => ({
      name: file.name,
      path: file.path,
      size: file.size,
      isFile: file.isFile(),
      isDirectory: file.isDirectory()
    }));
  } catch (error) {
    console.error('Error listing files:', error);
    return [];
  }
};

// Download file
const downloadFile = async (url, fileName) => {
  try {
    const filePath = `${documentsPath}/${fileName}`;
    const download = RNFS.downloadFile({
      fromUrl: url,
      toFile: filePath,
      progress: (res) => {
        const progress = (res.bytesWritten / res.contentLength) * 100;
        console.log(`Download progress: ${progress.toFixed(2)}%`);
      }
    });
   
    const result = await download.promise;
    if (result.statusCode === 200) {
      console.log('File downloaded successfully');
      return filePath;
    }
  } catch (error) {
    console.error('Error downloading file:', error);
  }
  return null;
};

Data Persistence Strategies

Cache Management

class CacheManager {
  constructor() {
    this.cacheKey = 'app_cache';
    this.maxCacheSize = 10 * 1024 * 1024; // 10MB
    this.cacheTimeout = 24 * 60 * 60 * 1000; // 24 hours
  }

  async set(key, data, ttl = this.cacheTimeout) {
    try {
      const cacheItem = {
        data,
        timestamp: Date.now(),
        ttl
      };
     
      const cache = await this.getCache();
      cache[key] = cacheItem;
     
      await this.saveCache(cache);
      await this.cleanup();
    } catch (error) {
      console.error('Error setting cache:', error);
    }
  }

  async get(key) {
    try {
      const cache = await this.getCache();
      const item = cache[key];
     
      if (!item) return null;
     
      // Check if expired
      if (Date.now() - item.timestamp > item.ttl) {
        delete cache[key];
        await this.saveCache(cache);
        return null;
      }
     
      return item.data;
    } catch (error) {
      console.error('Error getting cache:', error);
      return null;
    }
  }

  async getCache() {
    try {
      const cacheData = await AsyncStorage.getItem(this.cacheKey);
      return cacheData ? JSON.parse(cacheData) : {};
    } catch (error) {
      console.error('Error getting cache:', error);
      return {};
    }
  }

  async saveCache(cache) {
    try {
      await AsyncStorage.setItem(this.cacheKey, JSON.stringify(cache));
    } catch (error) {
      console.error('Error saving cache:', error);
    }
  }

  async cleanup() {
    try {
      const cache = await this.getCache();
      const now = Date.now();
      let hasExpired = false;
     
      // Remove expired items
      for (const key in cache) {
        if (now - cache[key].timestamp > cache[key].ttl) {
          delete cache[key];
          hasExpired = true;
        }
      }
     
      if (hasExpired) {
        await this.saveCache(cache);
      }
     
      // Check cache size and remove oldest items if needed
      const cacheSize = JSON.stringify(cache).length;
      if (cacheSize > this.maxCacheSize) {
        const sortedKeys = Object.keys(cache).sort((a, b) =>
          cache[a].timestamp - cache[b].timestamp
        );
       
        while (JSON.stringify(cache).length > this.maxCacheSize && sortedKeys.length > 0) {
          const oldestKey = sortedKeys.shift();
          delete cache[oldestKey];
        }
       
        await this.saveCache(cache);
      }
    } catch (error) {
      console.error('Error cleaning cache:', error);
    }
  }

  async clear() {
    try {
      await AsyncStorage.removeItem(this.cacheKey);
    } catch (error) {
      console.error('Error clearing cache:', error);
    }
  }
}

// Usage
const cacheManager = new CacheManager();

// Cache API response
const fetchUserData = async (userId) => {
  const cacheKey = `user_${userId}`;
 
  // Try to get from cache first
  let userData = await cacheManager.get(cacheKey);
 
  if (!userData) {
    // Fetch from API
    const response = await fetch(`/api/users/${userId}`);
    userData = await response.json();
   
    // Store in cache
    await cacheManager.set(cacheKey, userData);
  }
 
  return userData;
};

Offline Data Sync

class OfflineDataManager {
  constructor() {
    this.queueKey = 'offline_queue';
    this.dataKey = 'offline_data';
  }

  async addToQueue(action) {
    try {
      const queue = await this.getQueue();
      queue.push({
        id: Date.now(),
        ...action,
        timestamp: Date.now()
      });
     
      await AsyncStorage.setItem(this.queueKey, JSON.stringify(queue));
    } catch (error) {
      console.error('Error adding to queue:', error);
    }
  }

  async getQueue() {
    try {
      const queueData = await AsyncStorage.getItem(this.queueKey);
      return queueData ? JSON.parse(queueData) : [];
    } catch (error) {
      console.error('Error getting queue:', error);
      return [];
    }
  }

  async syncWithServer() {
    try {
      const queue = await this.getQueue();
      const processedItems = [];
     
      for (const item of queue) {
        try {
          await this.processQueueItem(item);
          processedItems.push(item.id);
        } catch (error) {
          console.error('Error processing queue item:', error);
          // Keep failed items in queue for retry
        }
      }
     
      // Remove processed items from queue
      const updatedQueue = queue.filter(item => !processedItems.includes(item.id));
      await AsyncStorage.setItem(this.queueKey, JSON.stringify(updatedQueue));
     
      return processedItems.length;
    } catch (error) {
      console.error('Error syncing with server:', error);
      return 0;
    }
  }

  async processQueueItem(item) {
    const { type, data } = item;
   
    switch (type) {
      case 'CREATE_USER':
        await fetch('/api/users', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(data)
        });
        break;
       
      case 'UPDATE_USER':
        await fetch(`/api/users/${data.id}`, {
          method: 'PUT',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(data)
        });
        break;
       
      case 'DELETE_USER':
        await fetch(`/api/users/${data.id}`, {
          method: 'DELETE'
        });
        break;
       
      default:
        throw new Error(`Unknown action type: ${type}`);
    }
  }

  async storeOfflineData(key, data) {
    try {
      const offlineData = await this.getOfflineData();
      offlineData[key] = {
        data,
        timestamp: Date.now()
      };
     
      await AsyncStorage.setItem(this.dataKey, JSON.stringify(offlineData));
    } catch (error) {
      console.error('Error storing offline data:', error);
    }
  }

  async getOfflineData() {
    try {
      const offlineData = await AsyncStorage.getItem(this.dataKey);
      return offlineData ? JSON.parse(offlineData) : {};
    } catch (error) {
      console.error('Error getting offline data:', error);
      return {};
    }
  }
}

Best Practices

1. Choose the Right Storage Solution

  • AsyncStorage: Simple key-value pairs, user preferences, small data
  • MMKV: High-performance key-value storage, frequent read/write operations
  • SQLite: Complex data structures, relationships, large datasets
  • Secure Storage: Sensitive data, authentication tokens, credentials
  • File System: Large files, documents, media files

2. Error Handling

Always implement proper error handling for storage operations:

const safeStorageOperation = async (operation) => {
  try {
    return await operation();
  } catch (error) {
    console.error('Storage operation failed:', error);
   
    // Handle specific errors
    if (error.message.includes('QuotaExceededError')) {
      // Handle storage quota exceeded
      await clearOldData();
      return await operation(); // Retry
    }
   
    // Return default value or handle gracefully
    return null;
  }
};

3. Data Validation

Validate data before storing and after retrieving:

const validateAndStore = async (key, data, schema) => {
  try {
    // Validate data structure
    if (!schema.validate(data)) {
      throw new Error('Data validation failed');
    }
   
    await AsyncStorage.setItem(key, JSON.stringify(data));
  } catch (error) {
    console.error('Validation or storage failed:', error);
    throw error;
  }
};

const validateAndRetrieve = async (key, schema, defaultValue = null) => {
  try {
    const data = await AsyncStorage.getItem(key);
    if (!data) return defaultValue;
   
    const parsedData = JSON.parse(data);
   
    // Validate retrieved data
    if (!schema.validate(parsedData)) {
      console.warn('Retrieved data is invalid, returning default');
      return defaultValue;
    }
   
    return parsedData;
  } catch (error) {
    console.error('Retrieval or validation failed:', error);
    return defaultValue;
  }
};

4. Performance Optimization

  • Batch operations when possible
  • Use appropriate data types
  • Implement caching strategies
  • Clean up unused data regularly
// Batch operations
const batchAsyncStorageOperations = async (operations) => {
  const promises = operations.map(op => {
    switch (op.type) {
      case 'set':
        return AsyncStorage.setItem(op.key, op.value);
      case 'get':
        return AsyncStorage.getItem(op.key);
      case 'remove':
        return AsyncStorage.removeItem(op.key);
      default:
        return Promise.resolve();
    }
  });
 
  return await Promise.all(promises);
};

5. Migration Strategies

Handle data structure changes gracefully:
const migrateData = async () => {
  try {
    const version = await AsyncStorage.getItem('data_version');
    const currentVersion = '1.2.0';
   
    if (version !== currentVersion) {
      await runMigrations(version, currentVersion);
      await AsyncStorage.setItem('data_version', currentVersion);
    }
  } catch (error) {
    console.error('Migration failed:', error);
  }
};

const runMigrations = async (fromVersion, toVersion) => {
  const migrations = {
    '1.0.0': async () => {
      // Migration from 1.0.0 to 1.1.0
      const oldData = await AsyncStorage.getItem('old_key');
      if (oldData) {
        const newData = transformOldData(JSON.parse(oldData));
        await AsyncStorage.setItem('new_key', JSON.stringify(newData));
        await AsyncStorage.removeItem('old_key');
      }
    },
    '1.1.0': async () => {
      // Migration from 1.1.0 to 1.2.0
      // Add new fields with default values
    }
  };
 
  // Run migrations in sequence
  for (const [version, migration] of Object.entries(migrations)) {
    if (shouldRunMigration(fromVersion, version, toVersion)) {
      await migration();
    }
  }
};

Summary

Data persistence is a crucial aspect of mobile app development. React Native provides multiple storage options, each with its own strengths:

  • AsyncStorage for simple key-value storage
  • MMKV for high-performance key-value operations
  • SQLite for complex relational data
  • Secure Storage for sensitive information
  • File System for direct file operations

Choose the appropriate storage solution based on your data structure, performance requirements, and security needs. Always implement proper error handling, data validation, and consider migration strategies for long-term maintenance.

In the next chapter, we’ll explore React Native Performance Optimization techniques to ensure your app runs smoothly even with large amounts of stored data.

Related Articles:

Scroll to Top