A Comprehensive Guide to Offline-First in React Native

Mobile apps face unique challenges when it comes to maintaining a seamless user experience. Unlike web apps where we generally assume constant connectivity, mobile apps must gracefully handle situations where network access is limited, unstable, or completely unavailable.

The Mobile Dev Starter Pack has a huge focus on offline-first functionality. We settled on a robust mixture of Zustand for reactivity, AsyncStorage/MMKV/SQLite for persistence, and Supabase for our remote database. However, this article provides a comprehensive overview of going offline in React Native in general – including persistence, sync, conflict resolution, and more.

Why Offline-First Matters Even More in Mobile

Mobile users encounter connectivity issues far more frequently than desktop users. Whether they’re commuting through areas with poor coverage, dealing with spotty Wi-Fi, or trying to conserve data usage, your app needs to work reliably regardless of network conditions. Why offline-first is useful today:

  1. User Experience: Network requests that fail or timeout lead to frustrated users and poor app ratings. An offline-first approach ensures your app remains functional and responsive even without connectivity.
  1. Data Conservation: Mobile data plans can be expensive and limited. By implementing proper caching and offline storage strategies, you help users minimize their data usage while still accessing the content they need.
  1. Performance: Even when users have network access, loading data from local storage is significantly faster than making network requests. This leads to a more responsive application and better user experience.
  1. Battery Life: Constant network requests drain mobile device batteries. Smart offline strategies can help reduce unnecessary network calls and preserve battery life.

React Native Offline Considerations

Data Storage Strategies

React Native developers have access to several local storage solutions, each optimized for different use cases. Understanding the performance characteristics and trade-offs of each option is crucial for building efficient offline-capable applications. Let’s explore these approaches and their performance implications. The main two are AsyncStorage and SQLite.

(MMKV is great, but has storage limitations.)

AsyncStorage: The Basic Building Block

AsyncStorage is React Native’s simple key-value storage system, similar to localStorage in web browsers. While it’s not suitable for complex data structures, it’s perfect for storing small amounts of data like user preferences or authentication tokens.

Here’s a practical example of using AsyncStorage:

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

// Storing data

const saveUserPreferences = async (preferences) => {

  try {

    await AsyncStorage.setItem(

      'userPreferences',

      JSON.stringify(preferences)

    );

  } catch (error) {

    console.error('Error saving preferences:', error);

  }

};

// Retrieving data

const loadUserPreferences = async () => {

  try {

    const savedPreferences = await AsyncStorage.getItem('userPreferences');

    return savedPreferences ? JSON.parse(savedPreferences) : null;

  } catch (error) {

    console.error('Error loading preferences:', error);

    return null;

  }

};

Let’s look at AsyncStorage’s performance characteristics and limitations:

Performance Profile:

  • Read operations: ~5-10ms for small items (<1KB)
  • Write operations: ~10-15ms for small items
  • Scales linearly with data size
  • Performance degrades significantly with items larger than 100KB

Key Limitations:

  • Not suitable for storing large amounts of data (recommended limit: 6MB total on Android)
  • Doesn’t support complex queries or indexing
  • All data must be serialized to strings (can become expensive!)
  • No support for concurrent transactions

For perspective, storing and retrieving a 1MB JSON object with AsyncStorage can take up to 200-300ms, while the same operation with SQLite might take only 50-70ms. This difference becomes more pronounced as data size increases.

SQLite: When You Need Relational Data

For more complex data requirements, SQLite provides a full-featured relational database that runs directly on the device. It significantly outperforms AsyncStorage for structured data and large datasets:

Performance Characteristics:

  • Bulk insertions: ~1000 records/second
  • Index-based queries: 1-5ms for simple queries
  • Complex JOIN operations: 10-50ms depending on table sizes
  • Support for transactions and concurrent operations
  • Efficient handling of large datasets (100MB+)

Key Use Cases:

  • Storing large amounts of structured data
  • Performing complex queries with multiple conditions
  • Maintaining data relationships
  • Handling concurrent operations

Alternative Storage Options: While SQLite is often the go-to choice for complex data storage, several other solutions are worth considering:

  • Realm: Offers excellent performance (2-3x faster than SQLite for most operations) and built-in synchronization, but comes with a steeper learning curve
  • PouchDB: Provides robust sync capabilities with CouchDB and works well for document-based data
  • WatermelonDB: Optimized for React Native with efficient querying and relationship handling

Here’s an example of setting up and using SQLite in React Native:

JavaScript
import SQLite from 'react-native-sqlite-storage';

const db = SQLite.openDatabase(

  {

    name: 'MainDB',

    location: 'default',

  },

  () => {

    console.log('Database connected!');

  },

  error => {

    console.error('Database error:', error);

  }

);

// Creating a table

const createTable = () => {

  db.transaction(tx => {

    tx.executeSql(

      'CREATE TABLE IF NOT EXISTS Posts (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, content TEXT, created_at INTEGER)',

      [],

      (tx, results) => {

        console.log('Table created successfully');

      },

      error => {

        console.error('Error creating table:', error);

      }

    );

  });

};

// Inserting data

const savePost = (title, content) => {

  db.transaction(tx => {

    tx.executeSql(

      'INSERT INTO Posts (title, content, created_at) VALUES (?, ?, ?)',

      [title, content, Date.now()],

      (tx, results) => {

        console.log('Post saved with id:', results.insertId);

      },

      error => {

        console.error('Error saving post:', error);

      }

    );

  });

};

// Handling SQLite Migrations

const SCHEMA_VERSION = 1;

const performMigrations = async () => {

  try {

    // Get current schema version from AsyncStorage

    const currentVersion = await AsyncStorage.getItem('SCHEMA_VERSION');

    const version = parseInt(currentVersion) || 0;

    if (version < SCHEMA_VERSION) {

      // Execute migrations in order

      if (version < 1) {

        await migrateToV1();

      }

      // Add more version checks as needed

      // Update schema version

      await AsyncStorage.setItem('SCHEMA_VERSION', SCHEMA_VERSION.toString());

    }

  } catch (error) {

    console.error('Migration failed:', error);

  }

};

const migrateToV1 = () => {

  return new Promise((resolve, reject) => {

    db.transaction(tx => {

      // Example migration: Add a new column to Posts table

      tx.executeSql(

        'ALTER TABLE Posts ADD COLUMN status TEXT DEFAULT "draft"',

        [],

        (tx, results) => {

          console.log('Migration to v1 completed');

          resolve();

        },

        error => {

          console.error('Migration to v1 failed:', error);

          reject(error);

        }

      );

    });

  });

};

/*

Migration Best Practices:

1. Always test migrations with real data

2. Back up data before migration when possible

3. Make migrations idempotent

4. Handle migration failures gracefully

5. Consider adding a rollback mechanism for critical migrations

*/

Network Strategies for Offline-First Applications

Understanding how to handle network connectivity in React Native requires thinking about networking in a fundamentally different way than we do for web applications. Instead of assuming constant connectivity and handling offline states as errors, we need to treat offline capability as a core feature of our application architecture.

Network Status Detection and Management

The first step in building offline-capable applications is implementing robust network status detection. React Native provides this capability through the @react-native-community/netinfo package. Let’s explore how to implement this effectively:

JavaScript
import NetInfo from '@react-native-community/netinfo';

// Create a network status manager to centralize network handling

class NetworkManager {

  constructor() {

    this.listeners = new Set();

    this.isConnected = true;

    // Initialize network monitoring

    NetInfo.addEventListener(state => {

      const wasConnected = this.isConnected;

      this.isConnected = state.isConnected && state.isInternetReachable;

      // Only notify if connection status actually changed

      if (wasConnected !== this.isConnected) {

        this.notifyListeners();

      }

      // If we're back online, trigger sync

      if (!wasConnected && this.isConnected) {

        this.handleReconnection();

      }

    });

  }

  // Allow components to subscribe to network changes

  addListener(callback) {

    this.listeners.add(callback);

    // Immediately notify of current status

    callback(this.isConnected);

    return () => this.listeners.delete(callback);

  }

  notifyListeners() {

    this.listeners.forEach(listener => listener(this.isConnected));

  }

  async handleReconnection() {

    // Trigger background sync when connection is restored

    await this.synchronizeOfflineActions();

  }

  async synchronizeOfflineActions() {

    // We'll implement this in the next section

  }

}

// Create a singleton instance

export const networkManager = new NetworkManager();

This network manager does more than simply detect connectivity—it provides a foundation for handling network state changes intelligently. By centralizing network status management, we can respond to connectivity changes consistently throughout our application.

Intelligent Request Handling with Axios

With our network status detection in place, we can build a sophisticated request handling system using Axios. This system will automatically handle offline scenarios and retry failed requests when appropriate:

JavaScript
import axios from 'axios';

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

// Create an axios instance with custom configuration

const api = axios.create({

  baseURL: 'https://api.yourapp.com',

  timeout: 10000,

});

// Queue to store requests made while offline

const requestQueue = {

  async addToQueue(request) {

    try {

      const queue = await this.getQueue();

      queue.push(request);

      await AsyncStorage.setItem('requestQueue', JSON.stringify(queue));

    } catch (error) {

      console.error('Error adding to queue:', error);

    }

  },

  async getQueue() {

    try {

      const queue = await AsyncStorage.getItem('requestQueue');

      return queue ? JSON.parse(queue) : [];

    } catch (error) {

      console.error('Error getting queue:', error);

      return [];

    }

  },

  async clearQueue() {

    await AsyncStorage.removeItem('requestQueue');

  }

};

// Add request interceptor to handle offline scenarios

api.interceptors.request.use(async config => {

  // Check if we're online

  const networkState = await NetInfo.fetch();

  const isConnected = networkState.isConnected && networkState.isInternetReachable;

  if (!isConnected) {

    // If the request is marked as requiring immediate execution, throw an error

    if (config.requiresConnectivity) {

      throw new Error('No network connectivity');

    }

    // Otherwise, queue the request for later

    await requestQueue.addToQueue({

      url: config.url,

      method: config.method,

      data: config.data,

      headers: config.headers,

      timestamp: Date.now(),

    });

    // Throw a special error that we can handle in our UI

    const error = new Error('Request queued');

    error.isQueued = true;

    throw error;

  }

  return config;

}, error => {

  return Promise.reject(error);

});

// Implement queue processing when back online

NetworkManager.prototype.synchronizeOfflineActions = async function() {

  const queue = await requestQueue.getQueue();

  if (queue.length === 0) return;

  console.log(`Processing ${queue.length} queued requests`);

  const results = await Promise.allSettled(

    queue.map(async request => {

      try {

        const response = await api({

          ...request,

          // Add metadata to indicate this is a retry

          headers: {

            ...request.headers,

            'X-Retry-Count': '1',

          }

        });

        return response;

      } catch (error) {

        return Promise.reject(error);

      }

    })

  );

  // Clear the queue after processing

  await requestQueue.clearQueue();

  // Handle any failed requests

  const failedRequests = results.filter(r => r.status === 'rejected');

  if (failedRequests.length > 0) {

    console.error(`${failedRequests.length} requests failed to process`);

    // Implement your error handling strategy here

  }

};

This implementation provides several sophisticated features:

  1. Automatic request queueing when offline
  2. Persistent storage of queued requests
  3. Automatic retry when connectivity is restored
  4. Support for priority requests that require immediate connectivity
  5. Metadata tracking for retried requests

Optimistic Updates and Conflict Resolution

When building offline-first applications, optimistic updates can significantly improve the perceived performance of your app. However, they require careful handling of potential conflicts. Here’s how to implement this pattern safely:

JavaScript
// Define a data manager that handles both local and remote state

class DataManager {

  constructor() {

    this.localDb = SQLite.openDatabase({

      name: 'MainDB',

      location: 'default',

    });

    this.pendingChanges = new Map();

  }

  // Implement optimistic updates with version tracking

  async updateRecord(tableName, recordId, changes) {

    // First, get the current version of the record

    const currentRecord = await this.getRecord(tableName, recordId);

    const newVersion = (currentRecord?.version || 0) + 1;

    // Store the pending change with its version

    this.pendingChanges.set(recordId, {

      changes,

      version: newVersion,

      timestamp: Date.now()

    });

    // Immediately update local database

    await this.updateLocalRecord(tableName, recordId, {

      ...changes,

      version: newVersion,

      pending: true

    });

    try {

      // Attempt to sync with server

      const serverResponse = await api.post(`/sync/${tableName}/${recordId}`, {

        changes,

        baseVersion: currentRecord?.version,

        newVersion

      });

      // If successful, clear pending change and update local record

      this.pendingChanges.delete(recordId);

      await this.updateLocalRecord(tableName, recordId, {

        ...serverResponse.data,

        pending: false

      });

    } catch (error) {

      if (error.isQueued) {

        // Request was queued for later due to offline state

        // Nothing more to do as local state is already updated

        return;

      }

      if (error.response?.status === 409) {

        // Conflict detected - server has a newer version

        await this.handleConflict(tableName, recordId, changes, error.response.data);

      }

    }

  }

  async handleConflict(tableName, recordId, localChanges, serverData) {

    // Get the full history of pending changes for this record

    const pendingChange = this.pendingChanges.get(recordId);

    // Implement a three-way merge strategy

    const resolvedData = await this.mergeChanges(

      serverData,

      localChanges,

      pendingChange

    );

    // Update local record with resolved data

    await this.updateLocalRecord(tableName, recordId, {

      ...resolvedData,

      version: serverData.version,

      pending: false

    });

    // Clear pending change as it's now resolved

    this.pendingChanges.delete(recordId);

    // Notify user if manual intervention is needed

    if (resolvedData.needsReview) {

      this.notifyConflict(recordId, resolvedData);

    }

  }

  async mergeChanges(serverData, localChanges, pendingChange) {

    // Implement different merge strategies based on data type

    const strategies = {

      // Strategy for text fields: Use most recent change

      text: (server, local, base) => {

        const serverTime = new Date(server.timestamp);

        const localTime = new Date(local.timestamp);

        return serverTime > localTime ? server.value : local.value;

      },

      // Strategy for numerical fields: Handle concurrent updates

      number: (server, local, base) => {

        // If both made relative changes (like incrementing a counter),

        // we need to preserve both operations

        const serverDelta = server.value - base.value;

        const localDelta = local.value - base.value;

        return base.value + serverDelta + localDelta;

      },

      // Strategy for arrays: Smart merge based on operation type

      array: (server, local, base) => {

        // Implementation depends on your specific needs

        // Example: Merge unique items from both sources

        return [...new Set([...server.value, ...local.value])];

      }

    };

    // Apply appropriate merge strategy for each field

    const resolved = {};

    for (const [field, value] of Object.entries(localChanges)) {

      const strategy = strategies[this.getFieldType(field)] || strategies.text;

      resolved[field] = strategy(

        { value: serverData[field], timestamp: serverData.timestamp },

        { value, timestamp: pendingChange.timestamp },

        { value: pendingChange.originalValue }

      );

    }

    return resolved;

  }

}

// Example usage in a React component

function TodoItem({ id, title, onUpdate }) {

  const [isEditing, setIsEditing] = useState(false);

  const [pendingUpdate, setPendingUpdate] = useState(false);

  const handleUpdate = async (newTitle) => {

    setIsEditing(false);

    setPendingUpdate(true);

    try {

      await dataManager.updateRecord('todos', id, { title: newTitle });

    } catch (error) {

      if (!error.isQueued) {

        // Show error message to user

        Alert.alert('Update failed', error.message);

      }

    } finally {

      setPendingUpdate(false);

    }

  };

  return (

    <View style={styles.todoItem}>

      {isEditing ? (

        <TodoEditForm

          initialValue={title}

          onSubmit={handleUpdate}

          onCancel={() => setIsEditing(false)}

        />

      ) : (

        <>

          <Text style={[

            styles.todoText,

            pendingUpdate && styles.pendingUpdate

          ]}>

            {title}

          </Text>

          <TouchableOpacity

            onPress={() => setIsEditing(true)}

            disabled={pendingUpdate}

          >

            <Text>Edit</Text>

          </TouchableOpacity>

        </>

      )}

    </View>

  );

}

This implementation provides several sophisticated features for handling offline data updates:

  1. Version Tracking: Each record maintains a version number that increments with every change, allowing us to detect concurrent modifications.
  1. Optimistic Updates: Changes are immediately reflected in the local database and UI, while being queued for server synchronization.
  1. Conflict Detection: When syncing with the server, version numbers are compared to detect if any concurrent modifications have occurred.
  1. Smart Merge Strategies: Different types of data (text, numbers, arrays) can have different merge strategies when conflicts occur.
  1. Pending State Tracking: The UI reflects when changes are pending synchronization, providing visual feedback to users.

Testing Offline Functionality

Testing offline capabilities requires a systematic approach to ensure your app handles all possible scenarios gracefully. Network connectivity isn’t binary—it exists on a spectrum from fully connected to completely offline, with many states in between. Let’s explore how to comprehensively test these scenarios.

Unit Testing Core Offline Components

First, let’s set up a testing environment that can simulate various network conditions. We’ll use Jest and React Native Testing Library, along with some custom utilities to mock network behavior:

JavaScript
// __tests__/utils/networkMock.js

export class NetworkMockManager {

  constructor() {

    this.isConnected = true;

    this.listeners = new Set();

  }

  setConnectionStatus(status) {

    this.isConnected = status;

    this.notifyListeners();

  }

  addListener(callback) {

    this.listeners.add(callback);

    return () => this.listeners.delete(callback);

  }

  notifyListeners() {

    this.listeners.forEach(listener => 

      listener({

        isConnected: this.isConnected,

        isInternetReachable: this.isConnected

      })

    );

  }

}

// Mock NetInfo for all tests

jest.mock('@react-native-community/netinfo', () => {

  const mockManager = new NetworkMockManager();

  return {

    addEventListener: jest.fn(callback => mockManager.addListener(callback)),

    fetch: jest.fn(() => Promise.resolve({

      isConnected: mockManager.isConnected,

      isInternetReachable: mockManager.isConnected

    })),

    // Expose mock manager for test control

    __mockManager: mockManager

  };

});

// __tests__/DataManager.test.js

import { DataManager } from '../src/services/DataManager';

import NetInfo from '@react-native-community/netinfo';

describe('DataManager', () => {

  let dataManager;

  let networkManager;

  beforeEach(() => {

    dataManager = new DataManager();

    networkManager = NetInfo.__mockManager;

  });

  test('should queue updates when offline', async () => {

    // Simulate offline state

    networkManager.setConnectionStatus(false);

    const update = { title: 'New Title' };

    await dataManager.updateRecord('todos', '123', update);

    // Verify update is in queue

    const queue = await dataManager.getQueuedUpdates();

    expect(queue).toContainEqual(expect.objectContaining({

      recordId: '123',

      changes: update

    }));

  });

  test('should handle version conflicts correctly', async () => {

    // Simulate a conflict scenario

    networkManager.setConnectionStatus(true);

    // Set up initial state

    const initialData = { title: 'Original', version: 1 };

    await dataManager.updateLocalRecord('todos', '123', initialData);

    // Simulate server having a newer version

    const serverResponse = {

      status: 409,

      data: {

        title: 'Server Version',

        version: 2,

        timestamp: Date.now()

      }

    };

    // Mock API to return conflict

    jest.spyOn(global, 'fetch').mockImplementationOnce(() =>

      Promise.reject({ response: serverResponse })

    );

    // Attempt update

    const update = { title: 'Local Change' };

    await dataManager.updateRecord('todos', '123', update);

    // Verify conflict resolution

    const finalRecord = await dataManager.getRecord('todos', '123');

    expect(finalRecord.version).toBe(2);

    expect(finalRecord.pending).toBe(false);

  });

});

Integration Testing with Network Scenarios

Integration tests help ensure different parts of your offline system work together correctly. Here’s how to test complete offline workflows:

JavaScript
// __tests__/integration/OfflineWorkflow.test.js

import { render, fireEvent, waitFor } from '@testing-library/react-native';

import { TodoList } from '../src/components/TodoList';

import NetInfo from '@react-native-community/netinfo';

describe('TodoList Offline Workflow', () => {

  let networkManager;

  beforeEach(() => {

    networkManager = NetInfo.__mockManager;

  });

  test('should handle offline-online transition with pending changes', async () => {

    // Start online

    networkManager.setConnectionStatus(true);

    const { getByText, getByTestId } = render(<TodoList />);

    // Create a new todo while online

    fireEvent.press(getByText('Add Todo'));

    fireEvent.changeText(getByTestId('todo-input'), 'Buy groceries');

    fireEvent.press(getByText('Save'));

    // Verify todo is added

    await waitFor(() => {

      expect(getByText('Buy groceries')).toBeTruthy();

    });

    // Go offline and make changes

    networkManager.setConnectionStatus(false);

    // Edit the todo while offline

    fireEvent.press(getByText('Edit'));

    fireEvent.changeText(getByTestId('todo-input'), 'Buy groceries and milk');

    fireEvent.press(getByText('Save'));

    // Verify optimistic update

    expect(getByText('Buy groceries and milk')).toBeTruthy();

    expect(getByTestId('sync-pending-indicator')).toBeTruthy();

    // Go back online

    networkManager.setConnectionStatus(true);

    // Verify sync completion

    await waitFor(() => {

      expect(getByTestId('sync-pending-indicator')).toBeFalsy();

    });

  });

});

Manual Testing Procedures

While automated tests are crucial, certain scenarios are best verified through manual testing. Here’s a systematic approach for manual testing of offline functionality:

  1. Network State Transitions:
  • Test rapid online/offline transitions
  • Test poor connectivity scenarios (slow, intermittent)
  • Test airplane mode toggles
  • Test background/foreground transitions during sync
  1. Data Synchronization:
  • Make multiple offline changes and verify sync order
  • Test large data set synchronization
  • Verify data integrity after sync
  • Check conflict resolution UI flows
  1. User Experience:
  • Verify loading states and progress indicators
  • Test error messages and recovery flows
  • Check offline mode indicators
  • Verify data access while offline
  1. Edge Cases:
  • Test device storage limits
  • Verify behavior with corrupt local data
  • Test sync with very old offline changes
  • Check timeout handling

Common Edge Cases to Watch For

When testing offline functionality, pay special attention to these often-overlooked scenarios:

  1. Storage Quota Exceeded:
JavaScript
// Test handling of storage limits

test('should handle storage quota exceeded', async () => {

  // Fill storage to near capacity

  const largeData = new Array(1000000).fill('x').join('');

  await AsyncStorage.setItem('testData', largeData);

  // Attempt to queue a new update

  try {

    await dataManager.queueUpdate('todos', '123', { title: 'New Todo' });

    // Should handle gracefully

    expect(await dataManager.getErrorState()).toBe('STORAGE_WARNING');

  } catch (error) {

    fail('Should handle storage limit gracefully');

  }

});
  1. Partial Sync Failures:
JavaScript
test('should handle partial sync failures', async () => {

  const updates = [

    { id: '1', changes: { title: 'First' } },

    { id: '2', changes: { title: 'Second' } },

    { id: '3', changes: { title: 'Third' } }

  ];

  // Mock second update to fail

  mockApi.sync.mockImplementation(async (id) => {

    if (id === '2') throw new Error('Sync failed');

    return { success: true };

  });

  await dataManager.syncMultiple(updates);

  // Verify successful updates completed

  expect(await dataManager.getRecord('1')).toBeDefined();

  expect(await dataManager.getRecord('3')).toBeDefined();

  // Verify failed update still queued

  const pending = await dataManager.getPendingUpdates();

  expect(pending).toContainEqual(expect.objectContaining({

    id: '2'

  }));

});

You need to think about offline-first differently

Building truly offline-capable applications requires more than just handling the absence of network connectivity—it demands a fundamental shift in how we think about application architecture. Throughout this guide, we’ve explored the various components that make up a robust offline-first application, from data storage to synchronization strategies.

The success of an offline-first application rests on several foundational principles that should guide your development process. The most crucial principle is treating offline capability as a core feature rather than an error state. This means designing your data flow, user interface, and architecture with offline scenarios in mind from the very beginning.

Consider the difference between an online-first and offline-first approach in creating a new todo item:

JavaScript
// Online-first approach (problematic)

async function createTodo(todo) {

  try {

    const response = await api.post('/todos', todo);

    updateLocalState(response.data);

  } catch (error) {

    // Show error message

    showError('Failed to create todo');

  }

}

// Offline-first approach (recommended)

async function createTodo(todo) {

  // Generate a temporary ID for immediate local use

  const tempId = generateLocalId();

  // Immediately update local state

  const newTodo = {

    ...todo,

    id: tempId,

    status: 'pending',

    createdAt: Date.now()

  };

  // Update local state immediately

  updateLocalState(newTodo);

  // Attempt to sync with server

  try {

    const response = await api.post('/todos', todo);

    // Update local state with server response

    updateLocalState({

      ...response.data,

      status: 'synced'

    });

  } catch (error) {

    if (error.isOffline) {

      // Queue for later sync

      queueSync({

        type: 'CREATE_TODO',

        data: todo,

        tempId,

        timestamp: Date.now()

      });

    } else {

      // Handle other errors while maintaining local state

      updateLocalState({

        ...newTodo,

        status: 'error',

        error: error.message

      });

    }

  }

}