Skip to main content

Module 8: Native Features and Device APIs

File System

Read, write, download, and manage files on the device with expo-file-system

🎯 Learning Objectives

  • Understand the file system directories available on mobile devices
  • Read and write files using expo-file-system
  • Download files from the network and track progress
  • Manage cached data and storage efficiently
  • Implement common file management patterns

File System Overview

Mobile apps often need to work with filesβ€”downloading documents, caching images, storing user-generated content, or persisting data locally. The expo-file-system module provides a comprehensive API for interacting with the device's file system.

Why File System Access?

flowchart TD
    subgraph UseCases["Common Use Cases"]
        A[πŸ“₯ Download Files]
        B[πŸ“„ Read Documents]
        C[πŸ’Ύ Save User Data]
        D[πŸ–ΌοΈ Cache Images]
        E[πŸ“€ Upload Files]
    end
    
    subgraph FileSystem["expo-file-system"]
        F[readAsStringAsync]
        G[writeAsStringAsync]
        H[downloadAsync]
        I[copyAsync / moveAsync]
        J[deleteAsync]
    end
    
    subgraph Storage["Device Storage"]
        K[πŸ“ Document Directory]
        L[πŸ“ Cache Directory]
        M[πŸ“ Bundle Directory]
    end
    
    UseCases --> FileSystem
    FileSystem --> Storage
    
    style UseCases fill:#e3f2fd
    style FileSystem fill:#fff3e0
    style Storage fill:#e8f5e9

Installation

# Install expo-file-system
npx expo install expo-file-system

Basic Import and Usage

import * as FileSystem from 'expo-file-system';

// Access built-in directory URIs
const documentDir = FileSystem.documentDirectory;
const cacheDir = FileSystem.cacheDirectory;
const bundleDir = FileSystem.bundleDirectory; // Read-only

console.log('Document Directory:', documentDir);
// iOS: file:///var/mobile/.../Documents/
// Android: file:///data/user/0/com.yourapp/files/

console.log('Cache Directory:', cacheDir);
// iOS: file:///var/mobile/.../Library/Caches/
// Android: file:///data/user/0/com.yourapp/cache/

πŸ’‘ Directory URIs

All file paths in expo-file-system are URIs that start with file://. The module provides constants for the main directories, and you build file paths by concatenating filenames to these base URIs.

Understanding Directories

Mobile devices provide different directories for different purposes. Understanding when to use each is crucial for building well-behaved apps.

Directory Types

Directory Constant Use Case Persists
Document documentDirectory User data, important files βœ… Yes (backed up)
Cache cacheDirectory Temporary files, re-downloadable content ⚠️ May be cleared
Bundle bundleDirectory App assets (read-only) βœ… With app

Choosing the Right Directory

import * as FileSystem from 'expo-file-system';

// User-generated content that should persist
const userDataPath = FileSystem.documentDirectory + 'userData/';

// Downloaded files that can be re-downloaded
const downloadCachePath = FileSystem.cacheDirectory + 'downloads/';

// Temporary processing files
const tempPath = FileSystem.cacheDirectory + 'temp/';

// Function to get appropriate directory for file type
function getDirectoryForFileType(type: 'user' | 'cache' | 'temp'): string {
  switch (type) {
    case 'user':
      return FileSystem.documentDirectory + 'user/';
    case 'cache':
      return FileSystem.cacheDirectory + 'cache/';
    case 'temp':
      return FileSystem.cacheDirectory + 'temp/';
    default:
      return FileSystem.cacheDirectory;
  }
}

Creating Directories

import * as FileSystem from 'expo-file-system';

// Create a directory (including intermediate directories)
async function ensureDirectoryExists(dirPath: string): Promise<void> {
  const dirInfo = await FileSystem.getInfoAsync(dirPath);
  
  if (!dirInfo.exists) {
    await FileSystem.makeDirectoryAsync(dirPath, { 
      intermediates: true  // Create parent directories if needed
    });
    console.log('Directory created:', dirPath);
  }
}

// Usage
await ensureDirectoryExists(FileSystem.documentDirectory + 'images/thumbnails/');

// List directory contents
async function listDirectory(dirPath: string): Promise<string[]> {
  try {
    const files = await FileSystem.readDirectoryAsync(dirPath);
    return files;
  } catch (error) {
    console.error('Error reading directory:', error);
    return [];
  }
}

// Get file/directory info
async function getFileInfo(filePath: string) {
  const info = await FileSystem.getInfoAsync(filePath, {
    size: true,      // Include file size
    md5: false,      // Include MD5 hash (slower)
  });
  
  console.log('Exists:', info.exists);
  if (info.exists) {
    console.log('Is directory:', info.isDirectory);
    console.log('Size:', info.size, 'bytes');
    console.log('Modified:', info.modificationTime);
    console.log('URI:', info.uri);
  }
  
  return info;
}

Directory Structure Visualization

flowchart TD
    subgraph App["Your App's File System"]
        direction TB
        
        subgraph DocDir["πŸ“ documentDirectory"]
            D1[πŸ“ userData/]
            D2[πŸ“ exports/]
            D3[πŸ“„ settings.json]
        end
        
        subgraph CacheDir["πŸ“ cacheDirectory"]
            C1[πŸ“ images/]
            C2[πŸ“ downloads/]
            C3[πŸ“ temp/]
        end
        
        subgraph BundleDir["πŸ“ bundleDirectory"]
            B1[πŸ“„ assets/...]
            B2[πŸ“„ (read-only)]
        end
    end
    
    style DocDir fill:#e8f5e9
    style CacheDir fill:#fff3e0
    style BundleDir fill:#e3f2fd

Reading Files

The expo-file-system module provides several methods to read file contents, supporting both text and binary data.

Reading Text Files

import * as FileSystem from 'expo-file-system';

// Read file as string (UTF-8 by default)
async function readTextFile(filename: string): Promise<string | null> {
  const filePath = FileSystem.documentDirectory + filename;
  
  try {
    const fileInfo = await FileSystem.getInfoAsync(filePath);
    
    if (!fileInfo.exists) {
      console.log('File does not exist:', filename);
      return null;
    }
    
    const content = await FileSystem.readAsStringAsync(filePath);
    return content;
  } catch (error) {
    console.error('Error reading file:', error);
    return null;
  }
}

// Usage
const content = await readTextFile('notes.txt');
if (content) {
  console.log('File content:', content);
}

Reading JSON Files

import * as FileSystem from 'expo-file-system';

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

// Read and parse JSON file
async function readJsonFile<T>(filename: string): Promise<T | null> {
  const filePath = FileSystem.documentDirectory + filename;
  
  try {
    const fileInfo = await FileSystem.getInfoAsync(filePath);
    
    if (!fileInfo.exists) {
      return null;
    }
    
    const content = await FileSystem.readAsStringAsync(filePath);
    return JSON.parse(content) as T;
  } catch (error) {
    console.error('Error reading JSON file:', error);
    return null;
  }
}

// Usage
const settings = await readJsonFile<UserSettings>('settings.json');
if (settings) {
  console.log('Theme:', settings.theme);
  console.log('Notifications:', settings.notifications);
}

Reading Binary Files (Base64)

import * as FileSystem from 'expo-file-system';

// Read file as Base64 encoded string
async function readBinaryFile(filePath: string): Promise<string | null> {
  try {
    const content = await FileSystem.readAsStringAsync(filePath, {
      encoding: FileSystem.EncodingType.Base64,
    });
    return content;
  } catch (error) {
    console.error('Error reading binary file:', error);
    return null;
  }
}

// Read image as Base64 (useful for displaying or uploading)
async function readImageAsBase64(imagePath: string): Promise<string | null> {
  const base64 = await readBinaryFile(imagePath);
  
  if (base64) {
    // Can be used directly in Image source
    // { uri: `data:image/jpeg;base64,${base64}` }
    return base64;
  }
  
  return null;
}

// Get image data URI for display
async function getImageDataUri(imagePath: string): Promise<string | null> {
  const base64 = await readBinaryFile(imagePath);
  
  if (!base64) return null;
  
  // Determine MIME type from extension
  const extension = imagePath.split('.').pop()?.toLowerCase();
  const mimeTypes: Record<string, string> = {
    jpg: 'image/jpeg',
    jpeg: 'image/jpeg',
    png: 'image/png',
    gif: 'image/gif',
    webp: 'image/webp',
  };
  
  const mimeType = mimeTypes[extension || ''] || 'application/octet-stream';
  
  return `data:${mimeType};base64,${base64}`;
}

Reading with Encoding Options

import * as FileSystem from 'expo-file-system';

// Available encoding types
const encodingTypes = {
  // Default UTF-8 text encoding
  utf8: FileSystem.EncodingType.UTF8,
  
  // Base64 for binary data
  base64: FileSystem.EncodingType.Base64,
};

// Read with specific encoding
async function readWithEncoding(
  filePath: string, 
  encoding: FileSystem.EncodingType = FileSystem.EncodingType.UTF8
): Promise<string> {
  return await FileSystem.readAsStringAsync(filePath, { encoding });
}

// Example: Read a text file with explicit UTF-8 encoding
const textContent = await readWithEncoding(
  FileSystem.documentDirectory + 'data.txt',
  FileSystem.EncodingType.UTF8
);

// Example: Read an image as Base64
const imageBase64 = await readWithEncoding(
  FileSystem.cacheDirectory + 'image.png',
  FileSystem.EncodingType.Base64
);

⚠️ Memory Considerations

Reading large files into memory can cause performance issues or crashes. For files larger than a few megabytes, consider reading in chunks or streaming the content. Base64 encoding also increases the memory footprint by approximately 33%.

Writing Files

Writing files allows you to persist user data, cache processed content, or prepare files for sharing or uploading.

Writing Text Files

import * as FileSystem from 'expo-file-system';

// Write a string to a file
async function writeTextFile(
  filename: string, 
  content: string
): Promise<boolean> {
  const filePath = FileSystem.documentDirectory + filename;
  
  try {
    await FileSystem.writeAsStringAsync(filePath, content);
    console.log('File written successfully:', filename);
    return true;
  } catch (error) {
    console.error('Error writing file:', error);
    return false;
  }
}

// Usage
await writeTextFile('notes.txt', 'This is my note content.');
await writeTextFile('log.txt', `Log entry: ${new Date().toISOString()}`);

Writing JSON Files

import * as FileSystem from 'expo-file-system';

interface AppData {
  version: string;
  lastSync: string;
  items: Array<{ id: string; name: string }>;
}

// Write object as JSON file
async function writeJsonFile<T>(
  filename: string, 
  data: T
): Promise<boolean> {
  const filePath = FileSystem.documentDirectory + filename;
  
  try {
    const jsonString = JSON.stringify(data, null, 2);
    await FileSystem.writeAsStringAsync(filePath, jsonString);
    return true;
  } catch (error) {
    console.error('Error writing JSON file:', error);
    return false;
  }
}

// Usage
const appData: AppData = {
  version: '1.0.0',
  lastSync: new Date().toISOString(),
  items: [
    { id: '1', name: 'Item One' },
    { id: '2', name: 'Item Two' },
  ],
};

await writeJsonFile('appData.json', appData);

// Read-modify-write pattern
async function updateJsonFile<T extends object>(
  filename: string,
  updater: (data: T) => T,
  defaultValue: T
): Promise<boolean> {
  const filePath = FileSystem.documentDirectory + filename;
  
  try {
    // Read existing data
    let data: T = defaultValue;
    const info = await FileSystem.getInfoAsync(filePath);
    
    if (info.exists) {
      const content = await FileSystem.readAsStringAsync(filePath);
      data = JSON.parse(content);
    }
    
    // Apply updates
    const updatedData = updater(data);
    
    // Write back
    await FileSystem.writeAsStringAsync(
      filePath, 
      JSON.stringify(updatedData, null, 2)
    );
    
    return true;
  } catch (error) {
    console.error('Error updating JSON file:', error);
    return false;
  }
}

// Usage: Add an item to existing data
await updateJsonFile<AppData>(
  'appData.json',
  (data) => ({
    ...data,
    items: [...data.items, { id: '3', name: 'New Item' }],
    lastSync: new Date().toISOString(),
  }),
  { version: '1.0.0', lastSync: '', items: [] }
);

Writing Binary Files (Base64)

import * as FileSystem from 'expo-file-system';

// Write Base64 content as binary file
async function writeBinaryFile(
  filename: string, 
  base64Content: string
): Promise<string | null> {
  const filePath = FileSystem.documentDirectory + filename;
  
  try {
    await FileSystem.writeAsStringAsync(filePath, base64Content, {
      encoding: FileSystem.EncodingType.Base64,
    });
    return filePath;
  } catch (error) {
    console.error('Error writing binary file:', error);
    return null;
  }
}

// Save a Base64 image to file
async function saveBase64Image(
  base64Data: string, 
  filename: string
): Promise<string | null> {
  // Remove data URI prefix if present
  const base64Content = base64Data.replace(/^data:image\/\w+;base64,/, '');
  
  const filePath = await writeBinaryFile(filename, base64Content);
  return filePath;
}

// Usage
const imageBase64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ...';
const savedPath = await saveBase64Image(imageBase64, 'captured-image.png');

if (savedPath) {
  console.log('Image saved to:', savedPath);
}

Appending to Files

import * as FileSystem from 'expo-file-system';

// Append content to an existing file
async function appendToFile(
  filename: string, 
  content: string
): Promise<boolean> {
  const filePath = FileSystem.documentDirectory + filename;
  
  try {
    // Read existing content
    const info = await FileSystem.getInfoAsync(filePath);
    let existingContent = '';
    
    if (info.exists) {
      existingContent = await FileSystem.readAsStringAsync(filePath);
    }
    
    // Append new content
    const newContent = existingContent + content;
    await FileSystem.writeAsStringAsync(filePath, newContent);
    
    return true;
  } catch (error) {
    console.error('Error appending to file:', error);
    return false;
  }
}

// Log file utility
class FileLogger {
  private logPath: string;
  
  constructor(filename: string = 'app.log') {
    this.logPath = FileSystem.documentDirectory + filename;
  }
  
  async log(message: string, level: 'INFO' | 'WARN' | 'ERROR' = 'INFO') {
    const timestamp = new Date().toISOString();
    const logEntry = `[${timestamp}] [${level}] ${message}\n`;
    
    await appendToFile('app.log', logEntry);
  }
  
  async clear() {
    await FileSystem.deleteAsync(this.logPath, { idempotent: true });
  }
  
  async read(): Promise<string> {
    const info = await FileSystem.getInfoAsync(this.logPath);
    if (!info.exists) return '';
    
    return await FileSystem.readAsStringAsync(this.logPath);
  }
}

// Usage
const logger = new FileLogger();
await logger.log('App started');
await logger.log('User logged in', 'INFO');
await logger.log('API timeout', 'WARN');
await logger.log('Critical failure', 'ERROR');

Atomic Writes (Safe File Updates)

import * as FileSystem from 'expo-file-system';

// Write file atomically (write to temp, then rename)
async function writeFileAtomically(
  filePath: string, 
  content: string
): Promise<boolean> {
  const tempPath = filePath + '.tmp';
  
  try {
    // Write to temporary file
    await FileSystem.writeAsStringAsync(tempPath, content);
    
    // Delete original if exists
    const originalInfo = await FileSystem.getInfoAsync(filePath);
    if (originalInfo.exists) {
      await FileSystem.deleteAsync(filePath);
    }
    
    // Rename temp to original
    await FileSystem.moveAsync({
      from: tempPath,
      to: filePath,
    });
    
    return true;
  } catch (error) {
    // Clean up temp file if it exists
    const tempInfo = await FileSystem.getInfoAsync(tempPath);
    if (tempInfo.exists) {
      await FileSystem.deleteAsync(tempPath, { idempotent: true });
    }
    
    console.error('Error in atomic write:', error);
    return false;
  }
}

// Usage for critical data
const criticalData = JSON.stringify({ balance: 1000, lastUpdate: Date.now() });
await writeFileAtomically(
  FileSystem.documentDirectory + 'wallet.json',
  criticalData
);

Downloading Files

The downloadAsync method allows you to download files from the network directly to the device's file system, with optional progress tracking.

Basic Download

import * as FileSystem from 'expo-file-system';

// Simple file download
async function downloadFile(
  url: string, 
  filename: string
): Promise<string | null> {
  const localPath = FileSystem.cacheDirectory + filename;
  
  try {
    const result = await FileSystem.downloadAsync(url, localPath);
    
    if (result.status === 200) {
      console.log('Download complete:', result.uri);
      return result.uri;
    } else {
      console.error('Download failed with status:', result.status);
      return null;
    }
  } catch (error) {
    console.error('Download error:', error);
    return null;
  }
}

// Usage
const imageUrl = 'https://example.com/image.jpg';
const localUri = await downloadFile(imageUrl, 'downloaded-image.jpg');

if (localUri) {
  // Use the local file
  console.log('File saved to:', localUri);
}

Download with Progress Tracking

import * as FileSystem from 'expo-file-system';

interface DownloadProgress {
  totalBytesWritten: number;
  totalBytesExpectedToWrite: number;
  progress: number; // 0 to 1
}

// Download with progress callback
async function downloadWithProgress(
  url: string,
  filename: string,
  onProgress: (progress: DownloadProgress) => void
): Promise<string | null> {
  const localPath = FileSystem.cacheDirectory + filename;
  
  // Create download resumable
  const downloadResumable = FileSystem.createDownloadResumable(
    url,
    localPath,
    {},
    (downloadProgress) => {
      const progress = 
        downloadProgress.totalBytesWritten / 
        downloadProgress.totalBytesExpectedToWrite;
      
      onProgress({
        totalBytesWritten: downloadProgress.totalBytesWritten,
        totalBytesExpectedToWrite: downloadProgress.totalBytesExpectedToWrite,
        progress,
      });
    }
  );
  
  try {
    const result = await downloadResumable.downloadAsync();
    
    if (result?.status === 200) {
      return result.uri;
    }
    return null;
  } catch (error) {
    console.error('Download error:', error);
    return null;
  }
}

// Usage example
await downloadWithProgress(
  'https://example.com/large-file.zip',
  'file.zip',
  (progress) => {
    console.log(`Downloaded: ${Math.round(progress.progress * 100)}%`);
    console.log(`${progress.totalBytesWritten} / ${progress.totalBytesExpectedToWrite} bytes`);
  }
);

Resumable Downloads

import * as FileSystem from 'expo-file-system';
import AsyncStorage from '@react-native-async-storage/async-storage';

class ResumableDownloadManager {
  private downloadResumable: FileSystem.DownloadResumable | null = null;
  private storageKey: string;
  
  constructor(downloadId: string) {
    this.storageKey = `download_${downloadId}`;
  }
  
  async startDownload(
    url: string,
    localPath: string,
    onProgress?: (progress: number) => void
  ): Promise<string | null> {
    // Check for existing paused download
    const savedData = await AsyncStorage.getItem(this.storageKey);
    
    if (savedData) {
      // Resume existing download
      const { savableUri, savableOptions } = JSON.parse(savedData);
      
      this.downloadResumable = new FileSystem.DownloadResumable(
        savableUri.uri,
        savableUri.fileUri,
        savableOptions,
        (downloadProgress) => {
          const progress = 
            downloadProgress.totalBytesWritten / 
            downloadProgress.totalBytesExpectedToWrite;
          onProgress?.(progress);
        },
        savableUri.resumeData
      );
    } else {
      // Start new download
      this.downloadResumable = FileSystem.createDownloadResumable(
        url,
        localPath,
        {},
        (downloadProgress) => {
          const progress = 
            downloadProgress.totalBytesWritten / 
            downloadProgress.totalBytesExpectedToWrite;
          onProgress?.(progress);
        }
      );
    }
    
    try {
      const result = await this.downloadResumable.downloadAsync();
      
      // Clear saved state on completion
      await AsyncStorage.removeItem(this.storageKey);
      
      return result?.uri ?? null;
    } catch (error) {
      console.error('Download error:', error);
      return null;
    }
  }
  
  async pauseDownload(): Promise<void> {
    if (!this.downloadResumable) return;
    
    try {
      const savable = await this.downloadResumable.pauseAsync();
      
      // Save state for later resumption
      await AsyncStorage.setItem(
        this.storageKey,
        JSON.stringify(savable)
      );
      
      console.log('Download paused');
    } catch (error) {
      console.error('Error pausing download:', error);
    }
  }
  
  async cancelDownload(): Promise<void> {
    if (!this.downloadResumable) return;
    
    try {
      await this.downloadResumable.pauseAsync();
      await AsyncStorage.removeItem(this.storageKey);
      this.downloadResumable = null;
      
      console.log('Download cancelled');
    } catch (error) {
      console.error('Error cancelling download:', error);
    }
  }
}

// Usage
const manager = new ResumableDownloadManager('video_001');

// Start/resume download
const uri = await manager.startDownload(
  'https://example.com/large-video.mp4',
  FileSystem.cacheDirectory + 'video.mp4',
  (progress) => console.log(`Progress: ${Math.round(progress * 100)}%`)
);

// User pauses download
await manager.pauseDownload();

// Later, resume by calling startDownload again
// It will automatically detect and resume from saved state

Download Component Example

import React, { useState, useCallback } from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-native';
import * as FileSystem from 'expo-file-system';

interface DownloadButtonProps {
  url: string;
  filename: string;
  onComplete?: (uri: string) => void;
}

export function DownloadButton({ url, filename, onComplete }: DownloadButtonProps) {
  const [status, setStatus] = useState<'idle' | 'downloading' | 'complete' | 'error'>('idle');
  const [progress, setProgress] = useState(0);
  
  const handleDownload = useCallback(async () => {
    const localPath = FileSystem.cacheDirectory + filename;
    
    setStatus('downloading');
    setProgress(0);
    
    const downloadResumable = FileSystem.createDownloadResumable(
      url,
      localPath,
      {},
      (downloadProgress) => {
        const prog = 
          downloadProgress.totalBytesWritten / 
          downloadProgress.totalBytesExpectedToWrite;
        setProgress(prog);
      }
    );
    
    try {
      const result = await downloadResumable.downloadAsync();
      
      if (result?.status === 200) {
        setStatus('complete');
        onComplete?.(result.uri);
      } else {
        setStatus('error');
      }
    } catch (error) {
      setStatus('error');
      console.error('Download failed:', error);
    }
  }, [url, filename, onComplete]);
  
  const getButtonText = () => {
    switch (status) {
      case 'idle': return '⬇️ Download';
      case 'downloading': return `Downloading ${Math.round(progress * 100)}%`;
      case 'complete': return 'βœ“ Downloaded';
      case 'error': return '⚠️ Retry';
    }
  };
  
  return (
    <View style={styles.container}>
      <Pressable
        style={[
          styles.button,
          status === 'downloading' && styles.buttonDownloading,
          status === 'complete' && styles.buttonComplete,
          status === 'error' && styles.buttonError,
        ]}
        onPress={handleDownload}
        disabled={status === 'downloading'}
      >
        <Text style={styles.buttonText}>{getButtonText()}</Text>
      </Pressable>
      
      {status === 'downloading' && (
        <View style={styles.progressBarContainer}>
          <View 
            style={[styles.progressBar, { width: `${progress * 100}%` }]} 
          />
        </View>
      )}
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    padding: 16,
  },
  button: {
    backgroundColor: '#007AFF',
    paddingVertical: 12,
    paddingHorizontal: 24,
    borderRadius: 8,
    alignItems: 'center',
  },
  buttonDownloading: {
    backgroundColor: '#FF9500',
  },
  buttonComplete: {
    backgroundColor: '#34C759',
  },
  buttonError: {
    backgroundColor: '#FF3B30',
  },
  buttonText: {
    color: 'white',
    fontSize: 16,
    fontWeight: '600',
  },
  progressBarContainer: {
    height: 4,
    backgroundColor: '#E5E5E5',
    borderRadius: 2,
    marginTop: 12,
    overflow: 'hidden',
  },
  progressBar: {
    height: '100%',
    backgroundColor: '#007AFF',
  },
});

πŸ’‘ Download Headers

You can pass custom headers to downloads for authentication or other purposes:

FileSystem.createDownloadResumable(
  url,
  localPath,
  {
    headers: {
      'Authorization': 'Bearer token123',
      'Accept': 'application/octet-stream',
    },
  },
  callback
);

File Management Operations

Beyond reading and writing, you'll often need to copy, move, rename, and delete files. The expo-file-system module provides all the tools you need.

Copy Files

import * as FileSystem from 'expo-file-system';

// Copy a file to a new location
async function copyFile(
  sourceUri: string, 
  destFilename: string
): Promise<string | null> {
  const destUri = FileSystem.documentDirectory + destFilename;
  
  try {
    await FileSystem.copyAsync({
      from: sourceUri,
      to: destUri,
    });
    
    console.log('File copied to:', destUri);
    return destUri;
  } catch (error) {
    console.error('Error copying file:', error);
    return null;
  }
}

// Copy from cache to documents (for permanent storage)
async function saveCachedFile(
  cacheFilename: string, 
  saveFilename: string
): Promise<string | null> {
  const sourceUri = FileSystem.cacheDirectory + cacheFilename;
  const destUri = FileSystem.documentDirectory + saveFilename;
  
  const sourceInfo = await FileSystem.getInfoAsync(sourceUri);
  if (!sourceInfo.exists) {
    console.error('Source file does not exist');
    return null;
  }
  
  await FileSystem.copyAsync({ from: sourceUri, to: destUri });
  return destUri;
}

// Usage: Save a downloaded file permanently
const permanentPath = await saveCachedFile(
  'temp-download.pdf', 
  'saved-documents/my-document.pdf'
);

Move and Rename Files

import * as FileSystem from 'expo-file-system';

// Move a file to a new location
async function moveFile(
  sourceUri: string, 
  destUri: string
): Promise<boolean> {
  try {
    await FileSystem.moveAsync({
      from: sourceUri,
      to: destUri,
    });
    
    console.log('File moved successfully');
    return true;
  } catch (error) {
    console.error('Error moving file:', error);
    return false;
  }
}

// Rename a file (move within same directory)
async function renameFile(
  directory: string,
  oldName: string, 
  newName: string
): Promise<boolean> {
  const oldPath = directory + oldName;
  const newPath = directory + newName;
  
  return await moveFile(oldPath, newPath);
}

// Usage
await renameFile(
  FileSystem.documentDirectory,
  'old-filename.txt',
  'new-filename.txt'
);

// Move from cache to documents
await moveFile(
  FileSystem.cacheDirectory + 'processed-image.jpg',
  FileSystem.documentDirectory + 'photos/image-001.jpg'
);

Delete Files and Directories

import * as FileSystem from 'expo-file-system';

// Delete a single file
async function deleteFile(filePath: string): Promise<boolean> {
  try {
    await FileSystem.deleteAsync(filePath, { 
      idempotent: true  // Don't throw if file doesn't exist
    });
    console.log('File deleted:', filePath);
    return true;
  } catch (error) {
    console.error('Error deleting file:', error);
    return false;
  }
}

// Delete a directory and all its contents
async function deleteDirectory(dirPath: string): Promise<boolean> {
  try {
    const info = await FileSystem.getInfoAsync(dirPath);
    
    if (!info.exists) {
      console.log('Directory does not exist');
      return true;
    }
    
    await FileSystem.deleteAsync(dirPath, { idempotent: true });
    console.log('Directory deleted:', dirPath);
    return true;
  } catch (error) {
    console.error('Error deleting directory:', error);
    return false;
  }
}

// Delete multiple files
async function deleteFiles(filePaths: string[]): Promise<void> {
  await Promise.all(
    filePaths.map((path) => 
      FileSystem.deleteAsync(path, { idempotent: true })
    )
  );
}

// Delete files older than a certain date
async function deleteOldFiles(
  directory: string, 
  maxAgeMs: number
): Promise<number> {
  const files = await FileSystem.readDirectoryAsync(directory);
  const now = Date.now();
  let deletedCount = 0;
  
  for (const filename of files) {
    const filePath = directory + filename;
    const info = await FileSystem.getInfoAsync(filePath);
    
    if (info.exists && info.modificationTime) {
      const ageMs = now - info.modificationTime * 1000;
      
      if (ageMs > maxAgeMs) {
        await FileSystem.deleteAsync(filePath, { idempotent: true });
        deletedCount++;
      }
    }
  }
  
  return deletedCount;
}

// Usage: Delete files older than 7 days
const weekInMs = 7 * 24 * 60 * 60 * 1000;
const deletedCount = await deleteOldFiles(
  FileSystem.cacheDirectory + 'temp/',
  weekInMs
);
console.log(`Deleted ${deletedCount} old files`);

File Information and Metadata

import * as FileSystem from 'expo-file-system';

interface FileMetadata {
  exists: boolean;
  uri: string;
  size?: number;
  modificationTime?: Date;
  isDirectory: boolean;
  md5?: string;
}

// Get detailed file information
async function getFileMetadata(filePath: string): Promise<FileMetadata> {
  const info = await FileSystem.getInfoAsync(filePath, {
    size: true,
    md5: true,  // Calculate MD5 hash (can be slow for large files)
  });
  
  return {
    exists: info.exists,
    uri: info.uri,
    size: info.exists ? info.size : undefined,
    modificationTime: info.exists && info.modificationTime 
      ? new Date(info.modificationTime * 1000) 
      : undefined,
    isDirectory: info.exists ? info.isDirectory : false,
    md5: info.exists ? info.md5 : undefined,
  };
}

// Check if two files are identical (by MD5)
async function filesAreIdentical(
  filePath1: string, 
  filePath2: string
): Promise<boolean> {
  const [info1, info2] = await Promise.all([
    FileSystem.getInfoAsync(filePath1, { md5: true }),
    FileSystem.getInfoAsync(filePath2, { md5: true }),
  ]);
  
  if (!info1.exists || !info2.exists) {
    return false;
  }
  
  return info1.md5 === info2.md5;
}

// Get total size of a directory
async function getDirectorySize(dirPath: string): Promise<number> {
  let totalSize = 0;
  
  const items = await FileSystem.readDirectoryAsync(dirPath);
  
  for (const item of items) {
    const itemPath = dirPath + item;
    const info = await FileSystem.getInfoAsync(itemPath, { size: true });
    
    if (info.exists) {
      if (info.isDirectory) {
        // Recursively get subdirectory size
        totalSize += await getDirectorySize(itemPath + '/');
      } else {
        totalSize += info.size || 0;
      }
    }
  }
  
  return totalSize;
}

// Format file size for display
function formatFileSize(bytes: number): string {
  if (bytes === 0) return '0 Bytes';
  
  const k = 1024;
  const sizes = ['Bytes', 'KB', 'MB', 'GB'];
  const i = Math.floor(Math.log(bytes) / Math.log(k));
  
  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}

// Usage
const cacheSize = await getDirectorySize(FileSystem.cacheDirectory);
console.log('Cache size:', formatFileSize(cacheSize));

File Operations Diagram

flowchart LR
    subgraph Operations["File Operations"]
        A[copyAsync] --> |"Duplicate"| B[New File]
        C[moveAsync] --> |"Relocate"| D[New Location]
        E[deleteAsync] --> |"Remove"| F[Gone]
        G[getInfoAsync] --> |"Inspect"| H[Metadata]
    end
    
    subgraph Options["Common Options"]
        I["idempotent: true"]
        J["intermediates: true"]
        K["size: true, md5: true"]
    end
    
    style Operations fill:#e3f2fd
    style Options fill:#fff3e0

Cache Management

Effective cache management is crucial for mobile apps. You need to balance between keeping frequently accessed files available and not consuming too much device storage.

Cache Strategy Overview

flowchart TD
    A[Request Resource] --> B{In Cache?}
    B -->|Yes| C{Still Valid?}
    B -->|No| D[Download/Generate]
    C -->|Yes| E[Use Cached]
    C -->|No| D
    D --> F[Save to Cache]
    F --> E
    
    G[Storage Pressure] --> H{Cache Too Large?}
    H -->|Yes| I[Clean Old Files]
    H -->|No| J[No Action]
    
    style A fill:#e3f2fd
    style E fill:#e8f5e9
    style I fill:#fff3e0

Cache Manager Class

import * as FileSystem from 'expo-file-system';

interface CacheConfig {
  maxSize: number;        // Maximum cache size in bytes
  maxAge: number;         // Maximum file age in milliseconds
  directory: string;      // Cache subdirectory name
}

class CacheManager {
  private cacheDir: string;
  private maxSize: number;
  private maxAge: number;
  
  constructor(config: CacheConfig) {
    this.cacheDir = FileSystem.cacheDirectory + config.directory + '/';
    this.maxSize = config.maxSize;
    this.maxAge = config.maxAge;
  }
  
  async initialize(): Promise<void> {
    const dirInfo = await FileSystem.getInfoAsync(this.cacheDir);
    if (!dirInfo.exists) {
      await FileSystem.makeDirectoryAsync(this.cacheDir, { 
        intermediates: true 
      });
    }
  }
  
  // Generate cache key from URL
  private getCacheKey(url: string): string {
    // Simple hash function
    let hash = 0;
    for (let i = 0; i < url.length; i++) {
      const char = url.charCodeAt(i);
      hash = ((hash << 5) - hash) + char;
      hash = hash & hash;
    }
    
    // Get file extension from URL
    const urlPath = url.split('?')[0];
    const extension = urlPath.split('.').pop() || 'bin';
    
    return `${Math.abs(hash).toString(16)}.${extension}`;
  }
  
  // Get cached file path for a URL
  getCachePath(url: string): string {
    return this.cacheDir + this.getCacheKey(url);
  }
  
  // Check if URL is cached and valid
  async isCached(url: string): Promise<boolean> {
    const cachePath = this.getCachePath(url);
    const info = await FileSystem.getInfoAsync(cachePath);
    
    if (!info.exists) return false;
    
    // Check if file is too old
    if (info.modificationTime) {
      const ageMs = Date.now() - (info.modificationTime * 1000);
      if (ageMs > this.maxAge) {
        // File expired, delete it
        await FileSystem.deleteAsync(cachePath, { idempotent: true });
        return false;
      }
    }
    
    return true;
  }
  
  // Get cached file or download
  async get(url: string): Promise<string | null> {
    const cachePath = this.getCachePath(url);
    
    // Check cache first
    if (await this.isCached(url)) {
      return cachePath;
    }
    
    // Download and cache
    try {
      const result = await FileSystem.downloadAsync(url, cachePath);
      
      if (result.status === 200) {
        // Trigger cleanup if needed
        this.cleanup();
        return cachePath;
      }
      
      return null;
    } catch (error) {
      console.error('Cache download error:', error);
      return null;
    }
  }
  
  // Get current cache size
  async getSize(): Promise<number> {
    let totalSize = 0;
    
    try {
      const files = await FileSystem.readDirectoryAsync(this.cacheDir);
      
      for (const file of files) {
        const info = await FileSystem.getInfoAsync(
          this.cacheDir + file, 
          { size: true }
        );
        if (info.exists && info.size) {
          totalSize += info.size;
        }
      }
    } catch (error) {
      console.error('Error getting cache size:', error);
    }
    
    return totalSize;
  }
  
  // Clean up old or excess files
  async cleanup(): Promise<void> {
    try {
      const files = await FileSystem.readDirectoryAsync(this.cacheDir);
      const fileInfos: Array<{
        path: string;
        modTime: number;
        size: number;
      }> = [];
      
      // Gather file information
      for (const file of files) {
        const path = this.cacheDir + file;
        const info = await FileSystem.getInfoAsync(path, { size: true });
        
        if (info.exists && !info.isDirectory) {
          fileInfos.push({
            path,
            modTime: info.modificationTime || 0,
            size: info.size || 0,
          });
        }
      }
      
      // Sort by modification time (oldest first)
      fileInfos.sort((a, b) => a.modTime - b.modTime);
      
      const now = Date.now();
      let currentSize = fileInfos.reduce((sum, f) => sum + f.size, 0);
      
      for (const file of fileInfos) {
        const ageMs = now - (file.modTime * 1000);
        
        // Delete if too old or cache is too large
        if (ageMs > this.maxAge || currentSize > this.maxSize) {
          await FileSystem.deleteAsync(file.path, { idempotent: true });
          currentSize -= file.size;
        }
      }
    } catch (error) {
      console.error('Cache cleanup error:', error);
    }
  }
  
  // Clear entire cache
  async clear(): Promise<void> {
    try {
      await FileSystem.deleteAsync(this.cacheDir, { idempotent: true });
      await this.initialize();
    } catch (error) {
      console.error('Error clearing cache:', error);
    }
  }
}

// Usage
const imageCache = new CacheManager({
  maxSize: 100 * 1024 * 1024, // 100 MB
  maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
  directory: 'images',
});

await imageCache.initialize();

// Get cached image or download
const localPath = await imageCache.get('https://example.com/image.jpg');
if (localPath) {
  // Use the local file path
}

Image Cache Hook

import { useState, useEffect } from 'react';
import * as FileSystem from 'expo-file-system';

const IMAGE_CACHE_DIR = FileSystem.cacheDirectory + 'images/';

// Ensure cache directory exists
async function ensureCacheDir() {
  const info = await FileSystem.getInfoAsync(IMAGE_CACHE_DIR);
  if (!info.exists) {
    await FileSystem.makeDirectoryAsync(IMAGE_CACHE_DIR, { 
      intermediates: true 
    });
  }
}

// Get cache path for URL
function getCachePath(url: string): string {
  const filename = url.split('/').pop() || 'image';
  const hash = url.split('').reduce((a, b) => {
    a = ((a << 5) - a) + b.charCodeAt(0);
    return a & a;
  }, 0);
  return IMAGE_CACHE_DIR + `${Math.abs(hash)}_${filename}`;
}

// Hook for cached images
export function useCachedImage(url: string): {
  uri: string | null;
  loading: boolean;
  error: Error | null;
} {
  const [uri, setUri] = useState<string | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);
  
  useEffect(() => {
    let mounted = true;
    
    async function loadImage() {
      try {
        await ensureCacheDir();
        
        const cachePath = getCachePath(url);
        const info = await FileSystem.getInfoAsync(cachePath);
        
        if (info.exists) {
          // Use cached version
          if (mounted) {
            setUri(cachePath);
            setLoading(false);
          }
          return;
        }
        
        // Download and cache
        const result = await FileSystem.downloadAsync(url, cachePath);
        
        if (mounted) {
          if (result.status === 200) {
            setUri(cachePath);
          } else {
            setError(new Error(`Download failed: ${result.status}`));
          }
          setLoading(false);
        }
      } catch (err) {
        if (mounted) {
          setError(err as Error);
          setLoading(false);
        }
      }
    }
    
    loadImage();
    
    return () => {
      mounted = false;
    };
  }, [url]);
  
  return { uri, loading, error };
}

// Usage in component
function CachedImage({ url, style }: { url: string; style?: object }) {
  const { uri, loading, error } = useCachedImage(url);
  
  if (loading) {
    return <ActivityIndicator />;
  }
  
  if (error || !uri) {
    return <Text>Failed to load image</Text>;
  }
  
  return <Image source={{ uri }} style={style} />;
}

Storage Usage Component

import React, { useState, useEffect } from 'react';
import { View, Text, Pressable, StyleSheet, Alert } from 'react-native';
import * as FileSystem from 'expo-file-system';

interface StorageInfo {
  cacheSize: number;
  documentSize: number;
  freeSpace: number;
  totalSpace: number;
}

function formatBytes(bytes: number): string {
  if (bytes === 0) return '0 B';
  const k = 1024;
  const sizes = ['B', 'KB', 'MB', 'GB'];
  const i = Math.floor(Math.log(bytes) / Math.log(k));
  return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}

async function getDirectorySize(dir: string): Promise<number> {
  let size = 0;
  
  try {
    const files = await FileSystem.readDirectoryAsync(dir);
    
    for (const file of files) {
      const path = dir + file;
      const info = await FileSystem.getInfoAsync(path, { size: true });
      
      if (info.exists) {
        if (info.isDirectory) {
          size += await getDirectorySize(path + '/');
        } else {
          size += info.size || 0;
        }
      }
    }
  } catch (error) {
    // Directory might not exist
  }
  
  return size;
}

export function StorageManager() {
  const [storageInfo, setStorageInfo] = useState<StorageInfo | null>(null);
  const [loading, setLoading] = useState(true);
  
  const loadStorageInfo = async () => {
    setLoading(true);
    
    const [cacheSize, documentSize, diskInfo] = await Promise.all([
      getDirectorySize(FileSystem.cacheDirectory!),
      getDirectorySize(FileSystem.documentDirectory!),
      FileSystem.getFreeDiskStorageAsync(),
    ]);
    
    const totalInfo = await FileSystem.getTotalDiskCapacityAsync();
    
    setStorageInfo({
      cacheSize,
      documentSize,
      freeSpace: diskInfo,
      totalSpace: totalInfo,
    });
    
    setLoading(false);
  };
  
  useEffect(() => {
    loadStorageInfo();
  }, []);
  
  const clearCache = async () => {
    Alert.alert(
      'Clear Cache',
      'This will delete all cached files. Continue?',
      [
        { text: 'Cancel', style: 'cancel' },
        {
          text: 'Clear',
          style: 'destructive',
          onPress: async () => {
            await FileSystem.deleteAsync(
              FileSystem.cacheDirectory!, 
              { idempotent: true }
            );
            await loadStorageInfo();
            Alert.alert('Done', 'Cache cleared successfully');
          },
        },
      ]
    );
  };
  
  if (loading || !storageInfo) {
    return <Text>Loading storage info...</Text>;
  }
  
  return (
    <View style={styles.container}>
      <Text style={styles.title}>Storage</Text>
      
      <View style={styles.row}>
        <Text style={styles.label}>Cache</Text>
        <Text style={styles.value}>
          {formatBytes(storageInfo.cacheSize)}
        </Text>
      </View>
      
      <View style={styles.row}>
        <Text style={styles.label}>App Data</Text>
        <Text style={styles.value}>
          {formatBytes(storageInfo.documentSize)}
        </Text>
      </View>
      
      <View style={styles.row}>
        <Text style={styles.label}>Device Free</Text>
        <Text style={styles.value}>
          {formatBytes(storageInfo.freeSpace)} / {formatBytes(storageInfo.totalSpace)}
        </Text>
      </View>
      
      <Pressable 
        style={styles.clearButton} 
        onPress={clearCache}
      >
        <Text style={styles.clearButtonText}>Clear Cache</Text>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    padding: 16,
    backgroundColor: '#fff',
    borderRadius: 12,
  },
  title: {
    fontSize: 18,
    fontWeight: '600',
    marginBottom: 16,
  },
  row: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    paddingVertical: 12,
    borderBottomWidth: 1,
    borderBottomColor: '#eee',
  },
  label: {
    fontSize: 16,
    color: '#333',
  },
  value: {
    fontSize: 16,
    color: '#666',
  },
  clearButton: {
    marginTop: 16,
    backgroundColor: '#FF3B30',
    paddingVertical: 12,
    borderRadius: 8,
    alignItems: 'center',
  },
  clearButtonText: {
    color: 'white',
    fontSize: 16,
    fontWeight: '600',
  },
});

⚠️ Cache Cleanup Best Practices

  • Run cache cleanup in the background, not on the main thread
  • Use InteractionManager.runAfterInteractions to defer cleanup
  • Consider cleaning up when the app goes to background
  • Implement LRU (Least Recently Used) eviction for optimal cache performance
  • Monitor getFreeDiskStorageAsync to avoid running out of space

Hands-On Exercises

Exercise 1: Build a Note-Taking File Manager

Create a simple note-taking system that stores notes as individual files with full CRUD operations.

Requirements:

  • Save notes as JSON files with title, content, and timestamp
  • List all saved notes
  • Edit existing notes
  • Delete notes
  • Search notes by title
Show Solution
import * as FileSystem from 'expo-file-system';

interface Note {
  id: string;
  title: string;
  content: string;
  createdAt: string;
  updatedAt: string;
}

const NOTES_DIR = FileSystem.documentDirectory + 'notes/';

class NoteManager {
  async initialize(): Promise<void> {
    const info = await FileSystem.getInfoAsync(NOTES_DIR);
    if (!info.exists) {
      await FileSystem.makeDirectoryAsync(NOTES_DIR, { 
        intermediates: true 
      });
    }
  }
  
  private getNotePath(id: string): string {
    return NOTES_DIR + `${id}.json`;
  }
  
  async createNote(title: string, content: string): Promise<Note> {
    const id = Date.now().toString(36) + Math.random().toString(36).substr(2);
    const now = new Date().toISOString();
    
    const note: Note = {
      id,
      title,
      content,
      createdAt: now,
      updatedAt: now,
    };
    
    await FileSystem.writeAsStringAsync(
      this.getNotePath(id),
      JSON.stringify(note, null, 2)
    );
    
    return note;
  }
  
  async getNote(id: string): Promise<Note | null> {
    const path = this.getNotePath(id);
    const info = await FileSystem.getInfoAsync(path);
    
    if (!info.exists) return null;
    
    const content = await FileSystem.readAsStringAsync(path);
    return JSON.parse(content);
  }
  
  async updateNote(
    id: string, 
    updates: Partial<Pick<Note, 'title' | 'content'>>
  ): Promise<Note | null> {
    const note = await this.getNote(id);
    if (!note) return null;
    
    const updatedNote: Note = {
      ...note,
      ...updates,
      updatedAt: new Date().toISOString(),
    };
    
    await FileSystem.writeAsStringAsync(
      this.getNotePath(id),
      JSON.stringify(updatedNote, null, 2)
    );
    
    return updatedNote;
  }
  
  async deleteNote(id: string): Promise<boolean> {
    const path = this.getNotePath(id);
    
    try {
      await FileSystem.deleteAsync(path, { idempotent: true });
      return true;
    } catch (error) {
      return false;
    }
  }
  
  async listNotes(): Promise<Note[]> {
    const files = await FileSystem.readDirectoryAsync(NOTES_DIR);
    const notes: Note[] = [];
    
    for (const file of files) {
      if (file.endsWith('.json')) {
        const content = await FileSystem.readAsStringAsync(NOTES_DIR + file);
        notes.push(JSON.parse(content));
      }
    }
    
    // Sort by updated date, newest first
    return notes.sort((a, b) => 
      new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
    );
  }
  
  async searchNotes(query: string): Promise<Note[]> {
    const allNotes = await this.listNotes();
    const lowerQuery = query.toLowerCase();
    
    return allNotes.filter(note => 
      note.title.toLowerCase().includes(lowerQuery) ||
      note.content.toLowerCase().includes(lowerQuery)
    );
  }
}

// React Component using the NoteManager
import React, { useState, useEffect, useCallback } from 'react';
import { 
  View, Text, TextInput, FlatList, 
  Pressable, StyleSheet, Alert 
} from 'react-native';

export function NotesApp() {
  const [notes, setNotes] = useState<Note[]>([]);
  const [title, setTitle] = useState('');
  const [content, setContent] = useState('');
  const [searchQuery, setSearchQuery] = useState('');
  const [editingId, setEditingId] = useState<string | null>(null);
  
  const noteManager = new NoteManager();
  
  const loadNotes = useCallback(async () => {
    await noteManager.initialize();
    const loadedNotes = searchQuery 
      ? await noteManager.searchNotes(searchQuery)
      : await noteManager.listNotes();
    setNotes(loadedNotes);
  }, [searchQuery]);
  
  useEffect(() => {
    loadNotes();
  }, [loadNotes]);
  
  const handleSave = async () => {
    if (!title.trim()) {
      Alert.alert('Error', 'Title is required');
      return;
    }
    
    if (editingId) {
      await noteManager.updateNote(editingId, { title, content });
      setEditingId(null);
    } else {
      await noteManager.createNote(title, content);
    }
    
    setTitle('');
    setContent('');
    loadNotes();
  };
  
  const handleEdit = (note: Note) => {
    setEditingId(note.id);
    setTitle(note.title);
    setContent(note.content);
  };
  
  const handleDelete = (id: string) => {
    Alert.alert(
      'Delete Note',
      'Are you sure?',
      [
        { text: 'Cancel', style: 'cancel' },
        {
          text: 'Delete',
          style: 'destructive',
          onPress: async () => {
            await noteManager.deleteNote(id);
            loadNotes();
          },
        },
      ]
    );
  };
  
  return (
    <View style={styles.container}>
      <TextInput
        style={styles.searchInput}
        placeholder="Search notes..."
        value={searchQuery}
        onChangeText={setSearchQuery}
      />
      
      <View style={styles.form}>
        <TextInput
          style={styles.input}
          placeholder="Title"
          value={title}
          onChangeText={setTitle}
        />
        <TextInput
          style={[styles.input, styles.contentInput]}
          placeholder="Content"
          value={content}
          onChangeText={setContent}
          multiline
        />
        <Pressable style={styles.saveButton} onPress={handleSave}>
          <Text style={styles.saveButtonText}>
            {editingId ? 'Update Note' : 'Save Note'}
          </Text>
        </Pressable>
      </View>
      
      <FlatList
        data={notes}
        keyExtractor={(item) => item.id}
        renderItem={({ item }) => (
          <View style={styles.noteItem}>
            <View style={styles.noteContent}>
              <Text style={styles.noteTitle}>{item.title}</Text>
              <Text style={styles.notePreview} numberOfLines={2}>
                {item.content}
              </Text>
              <Text style={styles.noteDate}>
                {new Date(item.updatedAt).toLocaleDateString()}
              </Text>
            </View>
            <View style={styles.noteActions}>
              <Pressable onPress={() => handleEdit(item)}>
                <Text>✏️</Text>
              </Pressable>
              <Pressable onPress={() => handleDelete(item.id)}>
                <Text>πŸ—‘οΈ</Text>
              </Pressable>
            </View>
          </View>
        )}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 16 },
  searchInput: {
    borderWidth: 1,
    borderColor: '#ddd',
    borderRadius: 8,
    padding: 12,
    marginBottom: 16,
  },
  form: { marginBottom: 16 },
  input: {
    borderWidth: 1,
    borderColor: '#ddd',
    borderRadius: 8,
    padding: 12,
    marginBottom: 8,
  },
  contentInput: { height: 100, textAlignVertical: 'top' },
  saveButton: {
    backgroundColor: '#007AFF',
    padding: 12,
    borderRadius: 8,
    alignItems: 'center',
  },
  saveButtonText: { color: 'white', fontWeight: '600' },
  noteItem: {
    flexDirection: 'row',
    padding: 12,
    backgroundColor: '#f5f5f5',
    borderRadius: 8,
    marginBottom: 8,
  },
  noteContent: { flex: 1 },
  noteTitle: { fontSize: 16, fontWeight: '600' },
  notePreview: { color: '#666', marginTop: 4 },
  noteDate: { color: '#999', fontSize: 12, marginTop: 4 },
  noteActions: { flexDirection: 'row', gap: 12 },
});

Exercise 2: Build an Offline-Capable Image Gallery

Create an image gallery that downloads and caches remote images for offline viewing with download progress.

Requirements:

  • Download images from URLs with progress indicator
  • Cache downloaded images locally
  • Display cached images when offline
  • Show storage usage and allow cache clearing
  • Support pull-to-refresh for new images
Show Solution
import React, { useState, useEffect, useCallback } from 'react';
import {
  View, Text, Image, FlatList, Pressable,
  StyleSheet, RefreshControl, ActivityIndicator, Alert,
} from 'react-native';
import * as FileSystem from 'expo-file-system';

const IMAGE_CACHE_DIR = FileSystem.cacheDirectory + 'gallery/';

const SAMPLE_IMAGES = [
  'https://picsum.photos/id/10/400/300',
  'https://picsum.photos/id/20/400/300',
  'https://picsum.photos/id/30/400/300',
  'https://picsum.photos/id/40/400/300',
  'https://picsum.photos/id/50/400/300',
  'https://picsum.photos/id/60/400/300',
];

interface CachedImage {
  url: string;
  localUri: string | null;
  downloading: boolean;
  progress: number;
  error: boolean;
}

async function ensureCacheDir() {
  const info = await FileSystem.getInfoAsync(IMAGE_CACHE_DIR);
  if (!info.exists) {
    await FileSystem.makeDirectoryAsync(IMAGE_CACHE_DIR, { 
      intermediates: true 
    });
  }
}

function getLocalPath(url: string): string {
  const hash = url.split('').reduce((a, b) => {
    a = ((a << 5) - a) + b.charCodeAt(0);
    return a & a;
  }, 0);
  return IMAGE_CACHE_DIR + `image_${Math.abs(hash)}.jpg`;
}

async function getCacheSize(): Promise<number> {
  let size = 0;
  try {
    const files = await FileSystem.readDirectoryAsync(IMAGE_CACHE_DIR);
    for (const file of files) {
      const info = await FileSystem.getInfoAsync(
        IMAGE_CACHE_DIR + file, 
        { size: true }
      );
      if (info.exists && info.size) {
        size += info.size;
      }
    }
  } catch (e) {}
  return size;
}

export function OfflineGallery() {
  const [images, setImages] = useState<CachedImage[]>([]);
  const [refreshing, setRefreshing] = useState(false);
  const [cacheSize, setCacheSize] = useState(0);
  
  const initializeImages = useCallback(async () => {
    await ensureCacheDir();
    
    const initialImages: CachedImage[] = await Promise.all(
      SAMPLE_IMAGES.map(async (url) => {
        const localPath = getLocalPath(url);
        const info = await FileSystem.getInfoAsync(localPath);
        
        return {
          url,
          localUri: info.exists ? localPath : null,
          downloading: false,
          progress: info.exists ? 1 : 0,
          error: false,
        };
      })
    );
    
    setImages(initialImages);
    setCacheSize(await getCacheSize());
  }, []);
  
  useEffect(() => {
    initializeImages();
  }, [initializeImages]);
  
  const downloadImage = async (url: string) => {
    const localPath = getLocalPath(url);
    
    setImages(prev => prev.map(img => 
      img.url === url 
        ? { ...img, downloading: true, progress: 0, error: false }
        : img
    ));
    
    const downloadResumable = FileSystem.createDownloadResumable(
      url,
      localPath,
      {},
      (downloadProgress) => {
        const progress = 
          downloadProgress.totalBytesWritten / 
          downloadProgress.totalBytesExpectedToWrite;
        
        setImages(prev => prev.map(img =>
          img.url === url ? { ...img, progress } : img
        ));
      }
    );
    
    try {
      const result = await downloadResumable.downloadAsync();
      
      if (result?.status === 200) {
        setImages(prev => prev.map(img =>
          img.url === url 
            ? { ...img, localUri: result.uri, downloading: false, progress: 1 }
            : img
        ));
        setCacheSize(await getCacheSize());
      } else {
        throw new Error('Download failed');
      }
    } catch (error) {
      setImages(prev => prev.map(img =>
        img.url === url 
          ? { ...img, downloading: false, error: true }
          : img
      ));
    }
  };
  
  const downloadAll = async () => {
    const uncached = images.filter(img => !img.localUri);
    for (const img of uncached) {
      await downloadImage(img.url);
    }
  };
  
  const clearCache = async () => {
    Alert.alert(
      'Clear Cache',
      'Delete all cached images?',
      [
        { text: 'Cancel', style: 'cancel' },
        {
          text: 'Clear',
          style: 'destructive',
          onPress: async () => {
            await FileSystem.deleteAsync(IMAGE_CACHE_DIR, { idempotent: true });
            await initializeImages();
          },
        },
      ]
    );
  };
  
  const onRefresh = async () => {
    setRefreshing(true);
    await initializeImages();
    setRefreshing(false);
  };
  
  const formatSize = (bytes: number) => {
    if (bytes < 1024) return `${bytes} B`;
    if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
    return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
  };
  
  const renderImage = ({ item }: { item: CachedImage }) => (
    <View style={styles.imageCard}>
      {item.localUri ? (
        <Image source={{ uri: item.localUri }} style={styles.image} />
      ) : item.downloading ? (
        <View style={styles.placeholder}>
          <ActivityIndicator size="large" color="#007AFF" />
          <Text style={styles.progressText}>
            {Math.round(item.progress * 100)}%
          </Text>
          <View style={styles.progressBar}>
            <View 
              style={[
                styles.progressFill, 
                { width: `${item.progress * 100}%` }
              ]} 
            />
          </View>
        </View>
      ) : item.error ? (
        <Pressable 
          style={styles.placeholder}
          onPress={() => downloadImage(item.url)}
        >
          <Text style={styles.errorText}>⚠️ Failed</Text>
          <Text style={styles.retryText}>Tap to retry</Text>
        </Pressable>
      ) : (
        <Pressable 
          style={styles.placeholder}
          onPress={() => downloadImage(item.url)}
        >
          <Text style={styles.downloadText}>πŸ“₯</Text>
          <Text style={styles.tapText}>Tap to download</Text>
        </Pressable>
      )}
    </View>
  );
  
  return (
    <View style={styles.container}>
      <View style={styles.header}>
        <Text style={styles.title}>Offline Gallery</Text>
        <Text style={styles.cacheInfo}>
          Cache: {formatSize(cacheSize)}
        </Text>
      </View>
      
      <View style={styles.actions}>
        <Pressable style={styles.actionButton} onPress={downloadAll}>
          <Text style={styles.actionText}>Download All</Text>
        </Pressable>
        <Pressable 
          style={[styles.actionButton, styles.clearButton]} 
          onPress={clearCache}
        >
          <Text style={[styles.actionText, styles.clearText]}>
            Clear Cache
          </Text>
        </Pressable>
      </View>
      
      <FlatList
        data={images}
        keyExtractor={(item) => item.url}
        renderItem={renderImage}
        numColumns={2}
        contentContainerStyle={styles.grid}
        refreshControl={
          <RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
        }
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#f5f5f5' },
  header: {
    padding: 16,
    backgroundColor: 'white',
    borderBottomWidth: 1,
    borderBottomColor: '#eee',
  },
  title: { fontSize: 24, fontWeight: 'bold' },
  cacheInfo: { color: '#666', marginTop: 4 },
  actions: {
    flexDirection: 'row',
    padding: 12,
    gap: 12,
  },
  actionButton: {
    flex: 1,
    backgroundColor: '#007AFF',
    padding: 12,
    borderRadius: 8,
    alignItems: 'center',
  },
  clearButton: { backgroundColor: '#FF3B30' },
  actionText: { color: 'white', fontWeight: '600' },
  clearText: {},
  grid: { padding: 8 },
  imageCard: {
    flex: 1,
    margin: 4,
    aspectRatio: 4/3,
    borderRadius: 8,
    overflow: 'hidden',
    backgroundColor: '#ddd',
  },
  image: { width: '100%', height: '100%' },
  placeholder: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: '#e0e0e0',
  },
  progressText: { marginTop: 8, color: '#007AFF', fontWeight: '600' },
  progressBar: {
    width: '80%',
    height: 4,
    backgroundColor: '#ccc',
    borderRadius: 2,
    marginTop: 8,
    overflow: 'hidden',
  },
  progressFill: { height: '100%', backgroundColor: '#007AFF' },
  downloadText: { fontSize: 32 },
  tapText: { color: '#666', marginTop: 8 },
  errorText: { fontSize: 24 },
  retryText: { color: '#FF3B30', marginTop: 8 },
});

Summary

The file system is a powerful capability that enables your app to persist data, cache content for offline use, and manage user-generated files effectively.

🎯 Key Takeaways

  • Directories: Use documentDirectory for persistent user data, cacheDirectory for temporary/re-downloadable content
  • Reading: Use readAsStringAsync with appropriate encoding (UTF8 for text, Base64 for binary)
  • Writing: Use writeAsStringAsync and consider atomic writes for critical data
  • Downloading: Use createDownloadResumable for progress tracking and resumable downloads
  • File operations: copyAsync, moveAsync, deleteAsync for managing files
  • Cache management: Implement size limits, age-based expiration, and cleanup routines
  • Storage monitoring: Use getInfoAsync with size option and getFreeDiskStorageAsync

This concludes Module 8 on Native Features and Device APIs. You now have the knowledge to access camera, location, notifications, sensors, sharing, and file system capabilities in your React Native apps. In the next module, we'll explore animations and gestures to create fluid, delightful user experiences.