Building Offline-First Apps with React Native and Redux Persist

React Native, Redux|SEPTEMBER 6, 2025|3 VIEWS
Create robust mobile apps that work seamlessly offline using Redux Persist and smart caching strategies

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:

  1. Local-First Data Storage: Store all critical data locally
  2. Background Synchronization: Sync data when connectivity is available
  3. Conflict Resolution: Handle data conflicts intelligently
  4. Progressive Enhancement: Enhance features when online
  5. 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! 🚀📱