Building Offline-first Applications with React Native

Zidane Gimiga - Sep 18 - - Dev Community

Imagine your app being used by a retailer updating stock levels, a sales representative accessing customer data, or any user sending messages during intermittent connectivity. In all these cases, offline functionality can mean the difference between a seamless user experience and a frustrating one. This is where Offline-first thinking comes into play.

An Offline-first approach ensures your app remains functional even when the internet is unavailable. Apps like WhatsApp illustrate this concept perfectly. When you send a message while offline, it’s stored locally and automatically sent once the connection is restored. This seamless experience is achieved by leveraging local storage and monitoring network status. Whether it's through a database or device memory, the app continues to function, syncing stored data with the server when connectivity is available again.

In this article, I'll guide you through implementing offline support in React Native applications using local storage, database syncing, and Expo APIs. The benefits of an Offline-first approach include:

  1. Improved User Experience: Users are less likely to experience downtime, which increases their overall satisfaction.
  2. Data Consistency: Data is stored locally and synchronized when online, preventing data loss or corruption.
  3. Increased Engagement: Apps that work offline provide greater flexibility, increasing engagement and retention, especially in regions with unreliable internet.

Setting Up Offline Support with Expo and React Native

Expo is a great framework for React Native development because it abstracts many platform-specific configurations, allowing you to focus on building features. In this section, we’ll explore how to implement offline support in a simple React Native app using Expo, AsyncStorage for local storage, and NetInfo for network status detection.

1. Set up the project

First, let’s start by creating a new Expo-powered React Native project.

npx create-expo-app offline-first-app
cd offline-first-app
Enter fullscreen mode Exit fullscreen mode

Step 2: Installing Dependencies

For this example, we’ll use two key libraries:

@react-native-async-storage/async-storage: This library will allow us to store data on the device.
@react-native-community/netinfo: This library will help us detect network status, determining whether the device is online or offline.

Install the necessary packages:

expo install @react-native-async-storage/async-storage @react-native-community/netinfo
Enter fullscreen mode Exit fullscreen mode

Step 3: Implementing Offline Logic

Next, we’ll build a simple application that fetches data from an API when online and stores it locally for use when offline. We will start by setting up the basic structure in App.js:

import React, { useState, useEffect } from 'react';
import { StyleSheet, Text, View, Button, FlatList } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import NetInfo from '@react-native-community/netinfo';

const DATA_API = 'https://jsonplaceholder.typicode.com/posts';

export default function App() {
  const [data, setData] = useState([]);
  const [isOffline, setIsOffline] = useState(false);

  useEffect(() => {
    const loadData = async () => {
      // Check network status
      const netInfo = await NetInfo.fetch();
      setIsOffline(!netInfo.isConnected);

      if (netInfo.isConnected) {
        // Fetch data from API when online
        try {
          const response = await fetch(DATA_API);
          const result = await response.json();
          setData(result);

          // Cache the data for offline use
          await AsyncStorage.setItem('cachedData', JSON.stringify(result));
        } catch (error) {
          console.error('Failed to fetch data:', error);
        }
      } else {
        // Load data from AsyncStorage when offline
        try {
          const cachedData = await AsyncStorage.getItem('cachedData');
          if (cachedData) {
            setData(JSON.parse(cachedData));
          }
        } catch (error) {
          console.error('Failed to load data from cache:', error);
        }
      }
    };

    loadData();
  }, []);

  return (
    <View style={styles.container}>
      <Text style={styles.header}>Offline-First App</Text>
      <Text>Status: {isOffline ? 'Offline' : 'Online'}</Text>

      <FlatList
        data={data}
        keyExtractor={(item) => item.id.toString()}
        renderItem={({ item }) => (
          <View style={styles.item}>
            <Text style={styles.title}>{item.title}</Text>
          </View>
        )}
      />

      <Button title="Reload" onPress={() => loadData()} />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    paddingTop: 50,
    paddingHorizontal: 20,
    backgroundColor: '#fff',
  },
  header: {
    fontSize: 24,
    fontWeight: 'bold',
    marginBottom: 20,
  },
  item: {
    backgroundColor: '#f9c2ff',
    padding: 20,
    marginVertical: 8,
  },
  title: {
    fontSize: 16,
  },
});
Enter fullscreen mode Exit fullscreen mode

How Does It Work?

  1. Network Status Detection: Using the NetInfo library, we check whether the device is online or offline. If it’s online, the app fetches data from the API and caches it. If the device is offline, the app retrieves cached data from AsyncStorage.

  2. Data Caching: AsyncStorage allows us to store the data fetched from the API for offline access. This is essential for making the app functional without an active internet connection.

  3. Data Synchronization: When connectivity is restored, the app fetches fresh data from the API and updates the cache, ensuring users always have the most up-to-date information when they’re online.

Advanced Offline Capabilities and Key Considerations

You can build upon this basic functionality by integrating more advanced features, such as:

  1. Syncing Strategies: Some apps require advanced syncing strategies, where conflicts might arise (e.g., two users updating the same data offline). Tools like PouchDB or Firebase can help manage real-time data syncing and conflict resolution.

  2. Database Solutions: For more complex apps, you may want to use a local database like Realm or SQLite for handling larger datasets and more sophisticated queries.

  3. Optimistic Updates: In some apps, especially those with user-generated content like social media, it’s common to allow users to create, update, or delete data offline. You can implement optimistic updates, where changes are made in the UI immediately and synced with the server when the app reconnects to the internet.


Handling Complex Syncing and Conflict Resolution

In an offline-first app, conflicts arise when multiple users update the same data while offline and their changes are later synced with the server once the app reconnects to the internet. Handling these conflicts is crucial to maintain data consistency and provide a smooth user experience.

There are different strategies for resolving such conflicts, including:

  1. Last Write Wins (LWW)
  2. Manual Conflict Resolution
  3. Operational Transformation (OT)

I have some examples here for you to check.

1. Last Write Wins (LWW)

In this strategy, the most recent change (based on a timestamp) is accepted as the final value when syncing data. It is simple and works well for many applications, but it may lead to data loss if multiple users edit the same data.

Imagine you are building a note-taking app, if two users edit the same note while offline, the user who syncs their changes last will overwrite the previous user’s changes.

Let’s assume we have a local storage system (using AsyncStorage) and a remote server.

import AsyncStorage from '@react-native-async-storage/async-storage';

// Simulate syncing the note data with the server
const syncNoteWithServer = async (localNote) => {
  try {
    const response = await fetch('evernotestefan-skliarovv1.p.rapidapi.com/getNote', {
        'x-rapidapi-key': 'Sign Up for Key',
    'x-rapidapi-host': 'Evernotestefan-skliarovV1.p.rapidapi.com',
    'Content-Type': 'multipart/form-data; boundary=---011000010111000001101001'
    });
    const serverNote = await response.json();

    // Compare timestamps
    if (localNote.updatedAt > serverNote.updatedAt) {
      // Local version is newer, so overwrite the server
      await fetch('https://api.example.com/note', {
        method: 'PUT',
        body: JSON.stringify(localNote),
        headers: { 'Content-Type': 'application/json' },
      });
    } else {
      // Server version is newer, discard local changes
      await AsyncStorage.setItem('note', JSON.stringify(serverNote));
    }
  } catch (error) {
    console.error('Sync failed:', error);
  }
};

// Example usage
const localNote = {
  content: 'This is an updated note.',
  updatedAt: Date.now(), // Timestamp of the last local update
};

syncNoteWithServer(localNote);

Enter fullscreen mode Exit fullscreen mode

In this example:
The app compares the updatedAt timestamp of the local note (stored offline) with the note stored on the server.
If the local note is newer, it overwrites the server version. Otherwise, it discards local changes and updates the app with the server version.

Pros:

  1. Simple to implement.
  2. Works well for non-critical data.

Cons:

  1. May lead to data loss (e.g., if both users made significant changes).
2. Manual Conflict Resolution

With manual conflict resolution, the user is prompted to resolve conflicts when multiple versions of the same data exist. This approach is more user-friendly in scenarios where every change is valuable and users need to decide which data to keep.

Here is a potential case, in a collaborative editing app, two users edit the same document while offline. Once both versions are synced, the user is prompted to choose which version to keep or merge.

import AsyncStorage from '@react-native-async-storage/async-storage';
import { Alert } from 'react-native';

// Simulate syncing the document with the server
const syncDocumentWithServer = async (localDoc) => {
  try {
    const response = await fetch('https://document.com/document');
    const serverDoc = await response.json();

    if (localDoc.updatedAt !== serverDoc.updatedAt) {
      // Conflict detected, ask the user to resolve it
      Alert.alert(
        'Document Conflict',
        'Both you and another user have edited this document. Choose which version to keep.',
        [
          {
            text: 'Keep Local',
            onPress: async () => {
              // Overwrite the server with local changes
              await fetch('https://api.example.com/document', {
                method: 'PUT',
                body: JSON.stringify(localDoc),
                headers: { 'Content-Type': 'application/json' },
              });
            },
          },
          {
            text: 'Keep Server',
            onPress: async () => {
              // Discard local changes and update the app with the server version
              await AsyncStorage.setItem('document', JSON.stringify(serverDoc));
            },
          },
        ],
      );
    } else {
      // No conflict, proceed with syncing
      await AsyncStorage.setItem('document', JSON.stringify(serverDoc));
    }
  } catch (error) {
    console.error('Sync failed:', error);
  }
};

// Example usage
const localDoc = {
  content: 'This is my latest edit.',
  updatedAt: Date.now(), // Timestamp of the last local update
};

syncDocumentWithServer(localDoc);
Enter fullscreen mode Exit fullscreen mode

Here's what's happening
If the updatedAt timestamps differ between the local and server versions, the app alerts the user and asks them to choose which version to keep. The user can decide whether to keep the local or server version.

Pros:

  1. Ensures that no important data is lost.
  2. Suitable for collaborative apps where user input is valuable.

Cons:

  1. Requires user intervention, which can be disruptive.
  2. May confuse non-technical users.

3. Operational Transformation (OT)
Operational Transformation is a more advanced technique used in real-time collaboration apps like Google Docs. It automatically merges conflicting changes by transforming operations in a way that preserves both sets of edits. OT allows multiple users to work on the same document simultaneously, and their changes are merged intelligently.

In a document editor app, two users edit different parts of a document. OT ensures that both sets of edits are applied without overwriting each other.

This implementation is a bit complex and require specialized libraries, such as ShareDB or Yjs. Here’s a basic pseudocode example of how OT works:

// Example of transforming two concurrent operations
const operation1 = { type: 'insert', position: 5, value: 'Hello' }; // User 1 adds 'Hello' at position 5
const operation2 = { type: 'insert', position: 3, value: 'World' }; // User 2 adds 'World' at position 3

const transformOperations = (op1, op2) => {
  // If both operations modify different positions, no conflict
  if (op1.position !== op2.position) return [op1, op2];

  // If operations conflict, adjust positions accordingly
  if (op1.position > op2.position) op1.position += op2.value.length;
  else op2.position += op1.value.length;

  return [op1, op2];
};

// Transform the operations to avoid conflicts
const [transformedOp1, transformedOp2] = transformOperations(operation1, operation2);
Enter fullscreen mode Exit fullscreen mode

The positions of the two conflicting operations are adjusted so that they can both be applied without overwriting each other.

Pros:

  1. Ideal for real-time collaboration.
  2. Automatically resolves conflicts without user intervention.

Cons:

  1. Complex to implement.
  2. Requires specialized algorithms and libraries.

Conclusion
Each conflict resolution strategy comes with its trade-offs. For simpler apps, Last Write Wins may suffice. However, for collaborative apps where user data is crucial, Manual Conflict Resolution or more advanced techniques like Operational Transformation might be necessary. Choosing the right strategy depends on the complexity of your app and the importance of the data being modified.


I plan to create a series of articles that dive deeper into the following key topics:

Optimistic UI Updates – We'll explore how to immediately reflect changes made while offline in the UI, giving users the impression that their actions were successful. This approach greatly improves the user experience.

Using Service Workers for Web-Based Apps – If you're deploying your app on the web via React Native Web, I'll explain how service workers can enable offline caching and background syncing for Progressive Web Apps (PWAs). This ensures that users can access resources and data even when they’re offline.

Real-World Use Cases of Offline-First Apps – I’ll take a closer look at how apps like Google Maps, Slack, Trello, and Notion handle offline scenarios. By studying these examples, you’ll gain a better understanding of the practical applications of offline-first techniques.

Testing Offline Capabilities – We'll cover the importance of testing offline functionality and review tools like React Native Debugger, Expo tools, and the Network Link Conditioner (for iOS) to simulate network interruptions. I’ll also show you how to write tests using libraries like Jest and React Native Testing Library to ensure your app behaves correctly in offline conditions.

Performance and Storage Considerations – Performance isn’t just about speed; it’s also about user experience. I’ll discuss strategies for optimizing performance by reducing cached data and implementing data expiry policies to avoid overwhelming local storage.

Stay tuned devs.


Thank you for reading all the way through. I genuinely enjoy documenting and sharing my learnings. I plan to create more content, including video tutorials, which I'll share on Instagram and TikTok. If you're new here, I'm Zidane Gimiga, a software developer with a passion for optimizing user experiences. As technology becomes more integrated into our lives, it's essential to make it as easy and accessible as possible for everyone. Let's keep pushing for better, user-friendly solutions.

Oh, I'm on Github

. .
Terabox Video Player