Users don't always have internet. Here's how to make your React Native app work offline.
The Strategy
- Cache API responses locally
- Show cached data when offline
- Queue mutations for later
- Sync when connection returns
Install Dependencies
npm install @react-native-async-storage/async-storage
npm install @react-native-community/netinfoCheck 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.