Build Offline-First React Native Apps with AsyncStorage

March 2, 2024 - 3 min read

Users don't always have internet. Here's how to make your React Native app work offline.

The Strategy

  1. Cache API responses locally
  2. Show cached data when offline
  3. Queue mutations for later
  4. Sync when connection returns

Install Dependencies

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

Check Network Status

import NetInfo from '@react-native-community/netinfo';
import { useEffect, useState } from 'react';
 
function useNetworkStatus() {
  const [isOnline, setIsOnline] = useState(true);
 
  useEffect(() => {
    const unsubscribe = NetInfo.addEventListener(state => {
      setIsOnline(state.isConnected ?? true);
    });
 
    return () => unsubscribe();
  }, []);
 
  return isOnline;
}

Cache API Responses

import AsyncStorage from '@react-native-async-storage/async-storage';
 
const CACHE_EXPIRY = 1000 * 60 * 60; // 1 hour
 
async function fetchWithCache(url: string) {
  const cacheKey = `cache_${url}`;
 
  try {
    // Try to fetch fresh data
    const response = await fetch(url);
    const data = await response.json();
 
    // Cache it
    await AsyncStorage.setItem(cacheKey, JSON.stringify({
      data,
      timestamp: Date.now(),
    }));
 
    return data;
  } catch (error) {
    // Fetch failed, try cache
    const cached = await AsyncStorage.getItem(cacheKey);
 
    if (cached) {
      const { data, timestamp } = JSON.parse(cached);
 
      // Check if cache is still valid
      if (Date.now() - timestamp < CACHE_EXPIRY) {
        return data;
      }
    }
 
    throw error;
  }
}

Custom Hook for Cached Data

import { useState, useEffect } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
 
function useCachedFetch<T>(url: string) {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);
  const [isFromCache, setIsFromCache] = useState(false);
 
  useEffect(() => {
    async function fetchData() {
      const cacheKey = `cache_${url}`;
 
      try {
        // Show cached data immediately
        const cached = await AsyncStorage.getItem(cacheKey);
        if (cached) {
          const { data } = JSON.parse(cached);
          setData(data);
          setIsFromCache(true);
        }
 
        // Fetch fresh data
        const response = await fetch(url);
        const freshData = await response.json();
 
        setData(freshData);
        setIsFromCache(false);
 
        // Update cache
        await AsyncStorage.setItem(cacheKey, JSON.stringify({
          data: freshData,
          timestamp: Date.now(),
        }));
      } catch (err) {
        setError(err as Error);
      } finally {
        setLoading(false);
      }
    }
 
    fetchData();
  }, [url]);
 
  return { data, loading, error, isFromCache };
}
 
// Usage
function UserList() {
  const { data, loading, isFromCache } = useCachedFetch('/api/users');
 
  return (
    <View>
      {isFromCache && <Text style={styles.badge}>Offline data</Text>}
      {data?.map(user => <UserCard key={user.id} user={user} />)}
    </View>
  );
}

Queue Offline Mutations

When user performs an action offline, queue it:

const QUEUE_KEY = 'offline_queue';
 
async function queueAction(action: {
  type: string;
  url: string;
  method: string;
  body: any;
}) {
  const queue = await getQueue();
  queue.push({ ...action, id: Date.now() });
  await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(queue));
}
 
async function getQueue() {
  const queue = await AsyncStorage.getItem(QUEUE_KEY);
  return queue ? JSON.parse(queue) : [];
}
 
async function clearQueue() {
  await AsyncStorage.removeItem(QUEUE_KEY);
}

Sync When Online

import NetInfo from '@react-native-community/netinfo';
 
async function syncOfflineActions() {
  const queue = await getQueue();
 
  if (queue.length === 0) return;
 
  for (const action of queue) {
    try {
      await fetch(action.url, {
        method: action.method,
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(action.body),
      });
    } catch (error) {
      console.error('Sync failed for action:', action);
      return; // Stop syncing, will retry later
    }
  }
 
  await clearQueue();
}
 
// Listen for connection changes
NetInfo.addEventListener(state => {
  if (state.isConnected) {
    syncOfflineActions();
  }
});

Complete Example: Offline-First Todo App

import { useState, useEffect } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import NetInfo from '@react-native-community/netinfo';
 
const TODOS_KEY = 'todos';
const QUEUE_KEY = 'todo_queue';
 
function useTodos() {
  const [todos, setTodos] = useState([]);
  const [isOnline, setIsOnline] = useState(true);
 
  // Load from cache on mount
  useEffect(() => {
    loadTodos();
 
    const unsubscribe = NetInfo.addEventListener(state => {
      setIsOnline(state.isConnected ?? true);
      if (state.isConnected) {
        syncQueue();
      }
    });
 
    return () => unsubscribe();
  }, []);
 
  async function loadTodos() {
    // Try cache first
    const cached = await AsyncStorage.getItem(TODOS_KEY);
    if (cached) {
      setTodos(JSON.parse(cached));
    }
 
    // Then fetch fresh
    try {
      const response = await fetch('/api/todos');
      const data = await response.json();
      setTodos(data);
      await AsyncStorage.setItem(TODOS_KEY, JSON.stringify(data));
    } catch (e) {
      // Offline, use cache
    }
  }
 
  async function addTodo(text: string) {
    const newTodo = { id: Date.now(), text, completed: false };
 
    // Optimistic update
    const updated = [...todos, newTodo];
    setTodos(updated);
    await AsyncStorage.setItem(TODOS_KEY, JSON.stringify(updated));
 
    if (isOnline) {
      try {
        await fetch('/api/todos', {
          method: 'POST',
          body: JSON.stringify(newTodo),
        });
      } catch (e) {
        await queueAction({ type: 'ADD_TODO', todo: newTodo });
      }
    } else {
      await queueAction({ type: 'ADD_TODO', todo: newTodo });
    }
  }
 
  async function syncQueue() {
    const queue = await AsyncStorage.getItem(QUEUE_KEY);
    if (!queue) return;
 
    const actions = JSON.parse(queue);
    for (const action of actions) {
      if (action.type === 'ADD_TODO') {
        await fetch('/api/todos', {
          method: 'POST',
          body: JSON.stringify(action.todo),
        });
      }
    }
 
    await AsyncStorage.removeItem(QUEUE_KEY);
  }
 
  return { todos, addTodo, isOnline };
}

Show Offline Indicator

function OfflineBanner() {
  const isOnline = useNetworkStatus();
 
  if (isOnline) return null;
 
  return (
    <View style={styles.banner}>
      <Text style={styles.bannerText}>
        You're offline. Changes will sync when connected.
      </Text>
    </View>
  );
}
 
const styles = StyleSheet.create({
  banner: {
    backgroundColor: '#f59e0b',
    padding: 8,
    alignItems: 'center',
  },
  bannerText: {
    color: 'white',
    fontSize: 12,
  },
});

Storage Limits

AsyncStorage has limits:

  • Android: 6MB default
  • iOS: No hard limit, but keep it reasonable

For large data, consider:

  • SQLite (expo-sqlite)
  • WatermelonDB
  • Realm

Keep your cache lean and clean up old data periodically.

© 2026 Rahul Mandyal. All rights reserved.