Building Offline-First Apps with React Native and Redux Persist
Introduction
In today's mobile-first world, users expect apps to work regardless of their internet connection. Building offline-first applications ensures your users can continue using your app even when they're on a plane, in a subway, or in areas with poor connectivity. This approach not only improves user experience but also makes your app more resilient and performant.
In this comprehensive guide, we'll explore how to build offline-first React Native applications using Redux Persist, implement smart caching strategies, and handle data synchronization when connectivity is restored.
What You'll Learn
- Understanding offline-first architecture principles
- Setting up Redux Persist for state persistence
- Implementing offline data storage strategies
- Handling network connectivity detection
- Building conflict resolution for data synchronization
- Creating a robust offline user experience
Understanding Offline-First Architecture
Core Principles
Offline-first means designing your app to work primarily with local data, treating network connectivity as an enhancement rather than a requirement. This approach involves:
- Local-First Data Storage: Store all critical data locally
- Background Synchronization: Sync data when connectivity is available
- Conflict Resolution: Handle data conflicts intelligently
- Progressive Enhancement: Enhance features when online
- Graceful Degradation: Maintain core functionality offline
Benefits of Offline-First Apps
- Better User Experience: No loading screens or connection errors
- Improved Performance: Instant data access from local storage
- Increased Reliability: App works in any network condition
- Reduced Server Load: Less frequent API calls
- Better Retention: Users don't abandon the app due to connectivity issues
Setting Up the Project
Installing Dependencies
Let's start by setting up a new React Native project with the necessary dependencies:
# Create a new React Native project
npx react-native init OfflineFirstApp
cd OfflineFirstApp
# Install Redux and related packages
npm install @reduxjs/toolkit react-redux redux-persist
# Install async storage for persistence
npm install @react-native-async-storage/async-storage
# Install network info for connectivity detection
npm install @react-native-community/netinfo
# Install additional utilities
npm install react-native-offline-queue react-native-uuid
# For iOS, install pods
cd ios && pod install && cd ..
Project Structure
Let's organize our project with a clean structure:
src/
├── store/
│ ├── index.js
│ ├── persistConfig.js
│ └── slices/
│ ├── authSlice.js
│ ├── postsSlice.js
│ └── networkSlice.js
├── services/
│ ├── api.js
│ ├── storage.js
│ └── sync.js
├── hooks/
│ ├── useNetworkStatus.js
│ └── useOfflineQueue.js
├── components/
│ ├── OfflineIndicator.js
│ └── SyncStatus.js
└── utils/
├── networkUtils.js
└── dateUtils.js
Setting Up Redux Persist
Store Configuration
Create the main store with persistence:
// src/store/index.js
import { configureStore } from "@reduxjs/toolkit";
import { persistStore, persistReducer } from "redux-persist";
import { combineReducers } from "@reduxjs/toolkit";
import AsyncStorage from "@react-native-async-storage/async-storage";
import authSlice from "./slices/authSlice";
import postsSlice from "./slices/postsSlice";
import networkSlice from "./slices/networkSlice";
const persistConfig = {
key: "root",
storage: AsyncStorage,
whitelist: ["auth", "posts"], // Only persist auth and posts
blacklist: ["network"], // Don't persist network state
};
const rootReducer = combineReducers({
auth: authSlice,
posts: postsSlice,
network: networkSlice,
});
const persistedReducer = persistReducer(persistConfig, rootReducer);
export const store = configureStore({
reducer: persistedReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: ["persist/PERSIST", "persist/REHYDRATE"],
},
}),
});
export const persistor = persistStore(store);
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
Advanced Persist Configuration
For more granular control, create specific persist configs:
// src/store/persistConfig.js
import AsyncStorage from "@react-native-async-storage/async-storage";
import { createTransform } from "redux-persist";
// Transform to handle dates and complex objects
const dateTransform = createTransform(
// Transform state on its way to being serialized and persisted
(inboundState) => {
return {
...inboundState,
lastSyncTime: inboundState.lastSyncTime?.toISOString(),
};
},
// Transform state being rehydrated
(outboundState) => {
return {
...outboundState,
lastSyncTime: outboundState.lastSyncTime
? new Date(outboundState.lastSyncTime)
: null,
};
},
{ whitelist: ["posts"] }
);
export const authPersistConfig = {
key: "auth",
storage: AsyncStorage,
whitelist: ["user", "token", "isAuthenticated"],
};
export const postsPersistConfig = {
key: "posts",
storage: AsyncStorage,
transforms: [dateTransform],
whitelist: ["items", "drafts", "lastSyncTime"],
};
export const rootPersistConfig = {
key: "root",
storage: AsyncStorage,
whitelist: ["auth", "posts"],
blacklist: ["network"],
};
Creating Redux Slices for Offline Data
Posts Slice with Offline Support
// src/store/slices/postsSlice.js
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
import { v4 as uuidv4 } from "react-native-uuid";
// Async thunk for fetching posts
export const fetchPosts = createAsyncThunk(
"posts/fetchPosts",
async (_, { getState, rejectWithValue }) => {
try {
const response = await fetch("/api/posts");
const data = await response.json();
return data;
} catch (error) {
return rejectWithValue(error.message);
}
}
);
// Async thunk for creating posts (with offline support)
export const createPost = createAsyncThunk(
"posts/createPost",
async (postData, { getState, dispatch }) => {
const tempId = uuidv4();
const post = {
...postData,
id: tempId,
isLocal: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
// Add to local state immediately
dispatch(addLocalPost(post));
// Queue for sync when online
dispatch(addToSyncQueue({ action: "CREATE", data: post }));
return post;
}
);
const initialState = {
items: [],
drafts: [],
syncQueue: [],
lastSyncTime: null,
isLoading: false,
error: null,
};
const postsSlice = createSlice({
name: "posts",
initialState,
reducers: {
addLocalPost: (state, action) => {
state.items.unshift(action.payload);
},
updateLocalPost: (state, action) => {
const { id, updates } = action.payload;
const postIndex = state.items.findIndex((post) => post.id === id);
if (postIndex !== -1) {
state.items[postIndex] = {
...state.items[postIndex],
...updates,
updatedAt: new Date().toISOString(),
};
}
},
deleteLocalPost: (state, action) => {
const id = action.payload;
state.items = state.items.filter((post) => post.id !== id);
},
addToDrafts: (state, action) => {
const draft = {
...action.payload,
id: uuidv4(),
isDraft: true,
createdAt: new Date().toISOString(),
};
state.drafts.push(draft);
},
removeDraft: (state, action) => {
state.drafts = state.drafts.filter(
(draft) => draft.id !== action.payload
);
},
addToSyncQueue: (state, action) => {
state.syncQueue.push({
id: uuidv4(),
timestamp: new Date().toISOString(),
...action.payload,
});
},
removeSyncQueueItem: (state, action) => {
state.syncQueue = state.syncQueue.filter(
(item) => item.id !== action.payload
);
},
clearSyncQueue: (state) => {
state.syncQueue = [];
},
updateLastSyncTime: (state) => {
state.lastSyncTime = new Date();
},
mergePosts: (state, action) => {
const serverPosts = action.payload;
const localPosts = state.items.filter((post) => post.isLocal);
// Merge server posts with local posts, avoiding duplicates
const mergedPosts = [...serverPosts];
localPosts.forEach((localPost) => {
if (!serverPosts.find((serverPost) => serverPost.id === localPost.id)) {
mergedPosts.push(localPost);
}
});
state.items = mergedPosts.sort(
(a, b) => new Date(b.createdAt) - new Date(a.createdAt)
);
},
},
extraReducers: (builder) => {
builder
.addCase(fetchPosts.pending, (state) => {
state.isLoading = true;
state.error = null;
})
.addCase(fetchPosts.fulfilled, (state, action) => {
state.isLoading = false;
state.mergePosts(action.payload);
state.updateLastSyncTime();
})
.addCase(fetchPosts.rejected, (state, action) => {
state.isLoading = false;
state.error = action.payload;
});
},
});
export const {
addLocalPost,
updateLocalPost,
deleteLocalPost,
addToDrafts,
removeDraft,
addToSyncQueue,
removeSyncQueueItem,
clearSyncQueue,
updateLastSyncTime,
mergePosts,
} = postsSlice.actions;
export default postsSlice.reducer;
Network State Management
// src/store/slices/networkSlice.js
import { createSlice } from "@reduxjs/toolkit";
import NetInfo from "@react-native-community/netinfo";
const initialState = {
isConnected: true,
connectionType: "unknown",
isInternetReachable: true,
isSyncing: false,
lastConnectionTime: null,
};
const networkSlice = createSlice({
name: "network",
initialState,
reducers: {
updateNetworkStatus: (state, action) => {
const { isConnected, type, isInternetReachable } = action.payload;
// Track when we go from offline to online
if (!state.isConnected && isConnected) {
state.lastConnectionTime = new Date().toISOString();
}
state.isConnected = isConnected;
state.connectionType = type;
state.isInternetReachable = isInternetReachable;
},
setSyncStatus: (state, action) => {
state.isSyncing = action.payload;
},
},
});
export const { updateNetworkStatus, setSyncStatus } = networkSlice.actions;
export default networkSlice.reducer;
Network Connectivity Detection
Custom Hook for Network Status
// src/hooks/useNetworkStatus.js
import { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import NetInfo from "@react-native-community/netinfo";
import { updateNetworkStatus } from "../store/slices/networkSlice";
export const useNetworkStatus = () => {
const dispatch = useDispatch();
const networkState = useSelector((state) => state.network);
useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state) => {
dispatch(
updateNetworkStatus({
isConnected: state.isConnected,
type: state.type,
isInternetReachable: state.isInternetReachable,
})
);
});
return unsubscribe;
}, [dispatch]);
return networkState;
};
Network Status Component
// src/components/OfflineIndicator.js
import React from "react";
import { View, Text, StyleSheet, Animated } from "react-native";
import { useNetworkStatus } from "../hooks/useNetworkStatus";
const OfflineIndicator = () => {
const { isConnected, connectionType } = useNetworkStatus();
const slideAnim = new Animated.Value(isConnected ? -50 : 0);
React.useEffect(() => {
Animated.timing(slideAnim, {
toValue: isConnected ? -50 : 0,
duration: 300,
useNativeDriver: true,
}).start();
}, [isConnected]);
if (isConnected) return null;
return (
<Animated.View
style={[styles.container, { transform: [{ translateY: slideAnim }] }]}
>
<Text style={styles.text}>
📱 You're offline. Changes will sync when connected.
</Text>
</Animated.View>
);
};
const styles = StyleSheet.create({
container: {
backgroundColor: "#ff6b6b",
padding: 12,
alignItems: "center",
position: "absolute",
top: 0,
left: 0,
right: 0,
zIndex: 1000,
},
text: {
color: "white",
fontSize: 14,
fontWeight: "500",
},
});
export default OfflineIndicator;
Data Synchronization Strategy
Sync Service
// src/services/sync.js
import { store } from "../store";
import {
removeSyncQueueItem,
clearSyncQueue,
mergePosts,
setSyncStatus,
} from "../store/slices/postsSlice";
class SyncService {
constructor() {
this.isProcessing = false;
this.retryCount = 0;
this.maxRetries = 3;
}
async processSyncQueue() {
if (this.isProcessing) return;
this.isProcessing = true;
const state = store.getState();
const { syncQueue } = state.posts;
const { isConnected } = state.network;
if (!isConnected || syncQueue.length === 0) {
this.isProcessing = false;
return;
}
store.dispatch(setSyncStatus(true));
try {
// Process queue items in order
for (const item of syncQueue) {
await this.processSyncItem(item);
store.dispatch(removeSyncQueueItem(item.id));
}
// Fetch latest data after successful sync
await this.fetchLatestData();
this.retryCount = 0;
} catch (error) {
console.error("Sync failed:", error);
this.handleSyncError();
} finally {
store.dispatch(setSyncStatus(false));
this.isProcessing = false;
}
}
async processSyncItem(item) {
const { action, data } = item;
switch (action) {
case "CREATE":
return await this.syncCreatePost(data);
case "UPDATE":
return await this.syncUpdatePost(data);
case "DELETE":
return await this.syncDeletePost(data.id);
default:
throw new Error(`Unknown sync action: ${action}`);
}
}
async syncCreatePost(postData) {
const response = await fetch("/api/posts", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${store.getState().auth.token}`,
},
body: JSON.stringify({
title: postData.title,
content: postData.content,
// Don't send local-only fields
}),
});
if (!response.ok) {
throw new Error(`Failed to create post: ${response.statusText}`);
}
const serverPost = await response.json();
// Update local post with server data
store.dispatch(
updateLocalPost({
id: postData.id,
updates: {
...serverPost,
isLocal: false,
isSynced: true,
},
})
);
return serverPost;
}
async syncUpdatePost(postData) {
const response = await fetch(
`/api/posts/${postData.serverId || postData.id}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${store.getState().auth.token}`,
},
body: JSON.stringify(postData),
}
);
if (!response.ok) {
throw new Error(`Failed to update post: ${response.statusText}`);
}
return await response.json();
}
async syncDeletePost(postId) {
const response = await fetch(`/api/posts/${postId}`, {
method: "DELETE",
headers: {
Authorization: `Bearer ${store.getState().auth.token}`,
},
});
if (!response.ok) {
throw new Error(`Failed to delete post: ${response.statusText}`);
}
}
async fetchLatestData() {
const lastSyncTime = store.getState().posts.lastSyncTime;
const url = lastSyncTime
? `/api/posts?since=${lastSyncTime.toISOString()}`
: "/api/posts";
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${store.getState().auth.token}`,
},
});
if (!response.ok) {
throw new Error(`Failed to fetch posts: ${response.statusText}`);
}
const posts = await response.json();
store.dispatch(mergePosts(posts));
}
handleSyncError() {
this.retryCount++;
if (this.retryCount < this.maxRetries) {
// Retry with exponential backoff
const delay = Math.pow(2, this.retryCount) * 1000;
setTimeout(() => {
this.processSyncQueue();
}, delay);
} else {
console.error("Max sync retries exceeded");
this.retryCount = 0;
}
}
startAutoSync() {
// Auto-sync every 30 seconds when online
setInterval(() => {
if (store.getState().network.isConnected) {
this.processSyncQueue();
}
}, 30000);
}
}
export default new SyncService();
Offline Queue Management
Custom Hook for Offline Actions
// src/hooks/useOfflineQueue.js
import { useSelector, useDispatch } from "react-redux";
import { useNetworkStatus } from "./useNetworkStatus";
import { addToSyncQueue } from "../store/slices/postsSlice";
import syncService from "../services/sync";
export const useOfflineQueue = () => {
const dispatch = useDispatch();
const { isConnected } = useNetworkStatus();
const syncQueue = useSelector((state) => state.posts.syncQueue);
const queueAction = (action, data) => {
dispatch(addToSyncQueue({ action, data }));
// Try to sync immediately if online
if (isConnected) {
syncService.processSyncQueue();
}
};
const getSyncQueueCount = () => syncQueue.length;
const isPending = (itemId) => {
return syncQueue.some(
(item) => item.data.id === itemId || item.data.localId === itemId
);
};
return {
queueAction,
getSyncQueueCount,
isPending,
syncQueue,
};
};
Conflict Resolution
Conflict Resolution Strategy
// src/services/conflictResolution.js
export class ConflictResolver {
static resolvePostConflict(localPost, serverPost) {
// Strategy 1: Last-write-wins based on updatedAt timestamp
const localTime = new Date(localPost.updatedAt);
const serverTime = new Date(serverPost.updatedAt);
if (localTime > serverTime) {
return {
resolution: "LOCAL_WINS",
data: localPost,
message: "Local changes are newer",
};
} else if (serverTime > localTime) {
return {
resolution: "SERVER_WINS",
data: serverPost,
message: "Server changes are newer",
};
}
// Strategy 2: Field-level merge for same timestamp
return this.mergeFields(localPost, serverPost);
}
static mergeFields(localPost, serverPost) {
const merged = { ...serverPost };
// Custom merge logic for specific fields
if (localPost.title && localPost.title !== serverPost.title) {
merged.title = localPost.title; // Prefer local title changes
}
if (
localPost.content &&
localPost.content.length > serverPost.content.length
) {
merged.content = localPost.content; // Prefer longer content
}
return {
resolution: "MERGED",
data: merged,
message: "Changes merged automatically",
};
}
static async promptUserForResolution(localData, serverData) {
// In a real app, show a UI for user to choose
return new Promise((resolve) => {
// Mock user choice - in reality, show a modal or screen
setTimeout(() => {
resolve({
resolution: "LOCAL_WINS",
data: localData,
});
}, 1000);
});
}
}
Implementing Smart Caching
Cache Service
// src/services/cache.js
import AsyncStorage from "@react-native-async-storage/async-storage";
class CacheService {
constructor() {
this.cacheKeys = {
POSTS: "cache_posts",
USER_PROFILE: "cache_user_profile",
SETTINGS: "cache_settings",
};
this.cacheExpiry = {
POSTS: 1000 * 60 * 30, // 30 minutes
USER_PROFILE: 1000 * 60 * 60 * 24, // 24 hours
SETTINGS: 1000 * 60 * 60 * 24 * 7, // 7 days
};
}
async setCache(key, data, customExpiry = null) {
const cacheData = {
data,
timestamp: Date.now(),
expiry: customExpiry || this.cacheExpiry[key] || this.cacheExpiry.POSTS,
};
try {
await AsyncStorage.setItem(
this.cacheKeys[key] || key,
JSON.stringify(cacheData)
);
} catch (error) {
console.error("Cache set error:", error);
}
}
async getCache(key) {
try {
const cached = await AsyncStorage.getItem(this.cacheKeys[key] || key);
if (!cached) return null;
const cacheData = JSON.parse(cached);
const now = Date.now();
// Check if cache is expired
if (now - cacheData.timestamp > cacheData.expiry) {
await this.removeCache(key);
return null;
}
return cacheData.data;
} catch (error) {
console.error("Cache get error:", error);
return null;
}
}
async removeCache(key) {
try {
await AsyncStorage.removeItem(this.cacheKeys[key] || key);
} catch (error) {
console.error("Cache remove error:", error);
}
}
async clearAllCache() {
try {
const keys = Object.values(this.cacheKeys);
await AsyncStorage.multiRemove(keys);
} catch (error) {
console.error("Cache clear error:", error);
}
}
async getCacheSize() {
try {
const keys = await AsyncStorage.getAllKeys();
const cacheKeys = keys.filter((key) =>
Object.values(this.cacheKeys).includes(key)
);
return cacheKeys.length;
} catch (error) {
console.error("Cache size error:", error);
return 0;
}
}
}
export default new CacheService();
User Interface Components
Sync Status Component
// src/components/SyncStatus.js
import React from "react";
import { View, Text, ActivityIndicator, StyleSheet } from "react-native";
import { useSelector } from "react-redux";
import { useOfflineQueue } from "../hooks/useOfflineQueue";
const SyncStatus = () => {
const { isSyncing } = useSelector((state) => state.posts);
const { isConnected } = useSelector((state) => state.network);
const { getSyncQueueCount } = useOfflineQueue();
const queueCount = getSyncQueueCount();
if (!isConnected && queueCount === 0) return null;
return (
<View style={styles.container}>
{isSyncing ? (
<View style={styles.syncingContainer}>
<ActivityIndicator size="small" color="#007AFF" />
<Text style={styles.syncingText}>Syncing...</Text>
</View>
) : queueCount > 0 ? (
<Text style={styles.queueText}>{queueCount} changes pending sync</Text>
) : (
<Text style={styles.syncedText}>✓ All changes synced</Text>
)}
</View>
);
};
const styles = StyleSheet.create({
container: {
padding: 8,
backgroundColor: "#f8f9fa",
borderRadius: 6,
margin: 16,
},
syncingContainer: {
flexDirection: "row",
alignItems: "center",
},
syncingText: {
marginLeft: 8,
fontSize: 14,
color: "#007AFF",
},
queueText: {
fontSize: 14,
color: "#ff9500",
textAlign: "center",
},
syncedText: {
fontSize: 14,
color: "#34c759",
textAlign: "center",
},
});
export default SyncStatus;
Post Item with Offline Indicators
// src/components/PostItem.js
import React from "react";
import { View, Text, StyleSheet, TouchableOpacity } from "react-native";
import { useOfflineQueue } from "../hooks/useOfflineQueue";
const PostItem = ({ post, onPress }) => {
const { isPending } = useOfflineQueue();
const isLocalOnly = post.isLocal && !post.isSynced;
const pendingSync = isPending(post.id);
const getStatusIcon = () => {
if (pendingSync) return "🔄";
if (isLocalOnly) return "📱";
if (post.isSynced) return "✓";
return "";
};
const getStatusColor = () => {
if (pendingSync) return "#ff9500";
if (isLocalOnly) return "#007AFF";
return "#34c759";
};
return (
<TouchableOpacity style={styles.container} onPress={onPress}>
<View style={styles.header}>
<Text style={styles.title}>{post.title}</Text>
<View style={[styles.status, { backgroundColor: getStatusColor() }]}>
<Text style={styles.statusText}>{getStatusIcon()}</Text>
</View>
</View>
<Text style={styles.content} numberOfLines={2}>
{post.content}
</Text>
<Text style={styles.date}>
{new Date(post.createdAt).toLocaleDateString()}
</Text>
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
container: {
backgroundColor: "white",
padding: 16,
marginHorizontal: 16,
marginVertical: 8,
borderRadius: 8,
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
header: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 8,
},
title: {
fontSize: 18,
fontWeight: "bold",
flex: 1,
},
status: {
width: 24,
height: 24,
borderRadius: 12,
justifyContent: "center",
alignItems: "center",
marginLeft: 8,
},
statusText: {
color: "white",
fontSize: 12,
fontWeight: "bold",
},
content: {
fontSize: 14,
color: "#666",
marginBottom: 8,
},
date: {
fontSize: 12,
color: "#999",
},
});
export default PostItem;
App Setup and Integration
Main App Component
// App.js
import React, { useEffect } from "react";
import { Provider } from "react-redux";
import { PersistGate } from "redux-persist/integration/react";
import { store, persistor } from "./src/store";
import { AppState } from "react-native";
import syncService from "./src/services/sync";
import MainNavigator from "./src/navigation/MainNavigator";
import OfflineIndicator from "./src/components/OfflineIndicator";
import LoadingScreen from "./src/components/LoadingScreen";
const App = () => {
useEffect(() => {
// Start auto-sync service
syncService.startAutoSync();
// Handle app state changes
const handleAppStateChange = (nextAppState) => {
if (nextAppState === "active") {
// Trigger sync when app becomes active
syncService.processSyncQueue();
}
};
const subscription = AppState.addEventListener(
"change",
handleAppStateChange
);
return () => subscription?.remove();
}, []);
return (
<Provider store={store}>
<PersistGate loading={<LoadingScreen />} persistor={persistor}>
<OfflineIndicator />
<MainNavigator />
</PersistGate>
</Provider>
);
};
export default App;
Performance Optimization
Optimizing Large Lists
// src/components/OptimizedPostList.js
import React, { useMemo } from "react";
import { FlatList, View } from "react-native";
import { useSelector } from "react-redux";
import PostItem from "./PostItem";
const OptimizedPostList = () => {
const posts = useSelector((state) => state.posts.items);
// Memoize sorted posts to avoid re-sorting on every render
const sortedPosts = useMemo(() => {
return [...posts].sort(
(a, b) => new Date(b.createdAt) - new Date(a.createdAt)
);
}, [posts]);
const renderPost = ({ item }) => (
<PostItem post={item} onPress={() => navigateToPost(item.id)} />
);
const keyExtractor = (item) => item.id.toString();
return (
<FlatList
data={sortedPosts}
renderItem={renderPost}
keyExtractor={keyExtractor}
removeClippedSubviews={true}
maxToRenderPerBatch={10}
windowSize={10}
initialNumToRender={10}
getItemLayout={(data, index) => ({
length: 120, // Estimated item height
offset: 120 * index,
index,
})}
/>
);
};
export default OptimizedPostList;
Memory Management
// src/utils/memoryUtils.js
import { store } from "../store";
import cacheService from "../services/cache";
export const cleanupOldData = () => {
const state = store.getState();
const { items } = state.posts;
// Keep only last 100 posts in memory
if (items.length > 100) {
const recentPosts = items.slice(0, 100);
store.dispatch(setPosts(recentPosts));
}
};
export const clearExpiredCaches = async () => {
try {
// Clear expired caches periodically
const cacheKeys = ["POSTS", "USER_PROFILE", "SETTINGS"];
for (const key of cacheKeys) {
const cached = await cacheService.getCache(key);
if (!cached) {
// Cache was expired and automatically removed
console.log(`Expired cache cleared: ${key}`);
}
}
} catch (error) {
console.error("Cache cleanup error:", error);
}
};
// Run cleanup every 5 minutes
setInterval(() => {
cleanupOldData();
clearExpiredCaches();
}, 5 * 60 * 1000);
Testing Offline Functionality
Offline Testing Hook
// src/hooks/useOfflineTesting.js
import { useDispatch } from "react-redux";
import { updateNetworkStatus } from "../store/slices/networkSlice";
export const useOfflineTesting = () => {
const dispatch = useDispatch();
const simulateOffline = () => {
dispatch(
updateNetworkStatus({
isConnected: false,
type: "none",
isInternetReachable: false,
})
);
};
const simulateOnline = () => {
dispatch(
updateNetworkStatus({
isConnected: true,
type: "wifi",
isInternetReachable: true,
})
);
};
const simulateSlowConnection = () => {
dispatch(
updateNetworkStatus({
isConnected: true,
type: "cellular",
isInternetReachable: true,
})
);
};
return {
simulateOffline,
simulateOnline,
simulateSlowConnection,
};
};
Testing Component
// src/components/OfflineTestControls.js (Development only)
import React from "react";
import { View, TouchableOpacity, Text, StyleSheet } from "react-native";
import { useOfflineTesting } from "../hooks/useOfflineTesting";
const OfflineTestControls = () => {
const { simulateOffline, simulateOnline, simulateSlowConnection } =
useOfflineTesting();
if (__DEV__) {
return (
<View style={styles.container}>
<TouchableOpacity style={styles.button} onPress={simulateOffline}>
<Text style={styles.buttonText}>Simulate Offline</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.button} onPress={simulateOnline}>
<Text style={styles.buttonText}>Simulate Online</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.button}
onPress={simulateSlowConnection}
>
<Text style={styles.buttonText}>Simulate Slow</Text>
</TouchableOpacity>
</View>
);
}
return null;
};
const styles = StyleSheet.create({
container: {
flexDirection: "row",
justifyContent: "space-around",
padding: 16,
backgroundColor: "#f0f0f0",
},
button: {
backgroundColor: "#007AFF",
padding: 8,
borderRadius: 4,
},
buttonText: {
color: "white",
fontSize: 12,
},
});
export default OfflineTestControls;
Best Practices and Tips
1. Data Architecture
- Keep critical data local: Store essential app data locally first
- Use optimistic updates: Update UI immediately, sync in background
- Implement proper versioning: Track data versions for conflict resolution
- Cache strategically: Cache frequently accessed data with appropriate TTL
2. User Experience
- Clear offline indicators: Show users when they're offline
- Sync status visibility: Display sync progress and queue status
- Graceful error handling: Handle sync failures gracefully
- Offline-first mindset: Design features to work offline first
3. Performance
- Lazy loading: Load data as needed
- Pagination: Implement proper pagination for large datasets
- Memory management: Clean up old data periodically
- Efficient storage: Use appropriate data structures
4. Security
- Encrypt sensitive data: Use proper encryption for stored data
- Validate synced data: Validate data integrity after sync
- Handle auth tokens: Manage authentication in offline scenarios
- Secure local storage: Protect against data tampering
Troubleshooting Common Issues
1. Redux Persist Issues
Problem: State not persisting properly
// Solution: Check persist configuration
const persistConfig = {
key: "root",
storage: AsyncStorage,
whitelist: ["auth", "posts"], // Make sure your slices are whitelisted
debug: __DEV__, // Enable debug in development
};
2. Sync Conflicts
Problem: Data conflicts during sync
// Solution: Implement proper conflict resolution
const handleConflict = async (localData, serverData) => {
const resolution = ConflictResolver.resolvePostConflict(
localData,
serverData
);
if (resolution.resolution === "USER_INPUT_REQUIRED") {
return await ConflictResolver.promptUserForResolution(
localData,
serverData
);
}
return resolution;
};
3. Memory Leaks
Problem: App consuming too much memory
// Solution: Implement proper cleanup
useEffect(() => {
const cleanup = () => {
// Clear large cached data
store.dispatch(clearLargeCache());
};
const interval = setInterval(cleanup, 10 * 60 * 1000); // Every 10 minutes
return () => clearInterval(interval);
}, []);
4. Network Detection Issues
Problem: Inaccurate network status
// Solution: Use multiple detection methods
const checkInternetConnectivity = async () => {
try {
const response = await fetch("https://httpbin.org/status/200", {
method: "HEAD",
timeout: 5000,
});
return response.ok;
} catch {
return false;
}
};
Conclusion
Building offline-first React Native applications with Redux Persist provides users with a seamless experience regardless of their network connectivity. By implementing the strategies and patterns outlined in this guide, you can create robust mobile applications that:
✅ Work reliably offline: Core functionality available without internet
✅ Sync intelligently: Efficient background synchronization
✅ Handle conflicts gracefully: Smart conflict resolution strategies
✅ Perform well: Optimized for mobile device constraints
✅ Provide great UX: Clear status indicators and smooth interactions
Key Takeaways:
- Start with local data: Design your app to work with local data first
- Implement smart caching: Cache appropriately with proper TTL
- Handle edge cases: Network failures, conflicts, and errors
- Test thoroughly: Test offline scenarios extensively
- Monitor performance: Keep an eye on memory and storage usage
Remember that offline-first is not just about handling network failures—it's about creating apps that feel instant and reliable. Users will appreciate apps that work consistently, regardless of their network conditions.
By following these patterns and continuously testing your offline functionality, you'll build React Native applications that users can depend on, leading to better user retention and satisfaction.
Happy coding! 🚀📱