Published on

Building an Offline-First App with React Native - A Comprehensive Guide (Notes Taking App)

Authors

In today's fast-paced world, bulding like note-taking apps have become essential tools for capturing ideas, thoughts, and important information on the go. However, users expect these apps to work seamlessly, even when their internet connection is unreliable or non-existent. This is where offline-first apps come into play. In this blog post, we'll explore how to build an offline-first notes app using React Native that syncs with the cloud when a connection is available.

If you want to understand in-depth about offline-first architecture and best practices, refer to this blog: Bulding Offline-First Apps - Architecture and Best Practices

Table of Contents

  1. Understanding Offline-First Architecture for a Notes App
  2. Choosing the Right Storage Solution for Notes
  3. Implementing Offline Storage for Notes
  4. Handling Rich Text and Images in Notes
  5. Syncing Notes with the Cloud
  6. Edge Cases and Considerations for a Notes App
  7. Conclusion

Understanding Offline-First Architecture for a Notes App

An offline-first notes app prioritizes local storage and processing of notes, ensuring that users can create, edit, and view their notes without an internet connection. When a connection becomes available, the app syncs its local data with the cloud.

Here's a simple diagram of the offline-first architecture for our notes app:

+-------------+     +----------------+     +-------------+
|   Notes App |<--->| Local Storage  |<--->| Cloud Server|
|  (React     |     | (AsyncStorage/ |     | (API)       |
|   Native)   |     |  SQLite)       |     |             |
+-------------+     +----------------+     +-------------+
                           ^
                           |
                    +----------------+
                    |  File System   |
                    | (Images/Assets)|
                    +----------------+

In this architecture:

  1. The Notes App interacts primarily with local storage.
  2. Local storage can be AsyncStorage, SQLite, or a combination of both.
  3. Images and other assets are stored in the file system.
  4. When online, the app syncs data with the cloud server.

Choosing the Right Storage Solution for Notes

For our notes app, we'll consider three main storage solutions:

  1. AsyncStorage: Ideal for simple, small-scale note-taking apps with basic text notes.
  2. SQLite: Perfect for more complex note structures, supporting rich text, tags, and advanced querying.
  3. Combination of AsyncStorage and SQLite: Offers flexibility for different types of data within the app.

Let's explore each option in detail:

Using AsyncStorage for Simple Notes

AsyncStorage is a good choice when:

  • Your notes are primarily short text entries.
  • You don't need complex querying or filtering.
  • The app doesn't require structured data relationships.

Example use case: A basic to-do list or simple note-taking app where each note is a single text entry.

Using SQLite for Complex Notes

SQLite is preferable when:

  • Notes contain rich text, multiple fields, or complex structures.
  • You need to perform complex queries or filters (e.g., searching notes by tags).
  • The app requires data relationships (e.g., notes belonging to notebooks).

Example use case: A feature-rich note-taking app with support for formatting, tags, notebooks, and advanced search capabilities.

Combining AsyncStorage and SQLite

A combination approach is useful when:

  • You have different types of data with varying complexity.
  • Some data needs quick access (AsyncStorage) while other data requires structured storage (SQLite).

Example use case: A notes app where user preferences and settings are stored in AsyncStorage, while the notes themselves are stored in SQLite.

Implementing Offline Storage for Notes

Let's implement offline storage for our notes app using all three approaches.

Using AsyncStorage for Simple Notes

First, install the necessary dependency:

npm install @react-native-async-storage/async-storage

Now, let's create a simple note service using AsyncStorage:

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

const NOTES_STORAGE_KEY = '@MyNotesApp_notes'

export const noteService = {
  saveNote: async (note) => {
    try {
      const existingNotes = await noteService.getAllNotes()
      const updatedNotes = [...existingNotes, { id: Date.now(), ...note }]
      await AsyncStorage.setItem(NOTES_STORAGE_KEY, JSON.stringify(updatedNotes))
    } catch (error) {
      console.error('Error saving note', error)
    }
  },

  getAllNotes: async () => {
    try {
      const notes = await AsyncStorage.getItem(NOTES_STORAGE_KEY)
      return notes ? JSON.parse(notes) : []
    } catch (error) {
      console.error('Error retrieving notes', error)
      return []
    }
  },

  updateNote: async (updatedNote) => {
    try {
      const existingNotes = await noteService.getAllNotes()
      const updatedNotes = existingNotes.map((note) =>
        note.id === updatedNote.id ? updatedNote : note
      )
      await AsyncStorage.setItem(NOTES_STORAGE_KEY, JSON.stringify(updatedNotes))
    } catch (error) {
      console.error('Error updating note', error)
    }
  },

  deleteNote: async (noteId) => {
    try {
      const existingNotes = await noteService.getAllNotes()
      const updatedNotes = existingNotes.filter((note) => note.id !== noteId)
      await AsyncStorage.setItem(NOTES_STORAGE_KEY, JSON.stringify(updatedNotes))
    } catch (error) {
      console.error('Error deleting note', error)
    }
  },
}

This implementation provides basic CRUD operations for simple text notes using AsyncStorage.

Using SQLite for Complex Notes

For more complex note structures, let's use SQLite. First, install the necessary dependency:

npm install react-native-sqlite-storage

Now, let's create a SQLite-based note service:

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

const db = SQLite.openDatabase(
  { name: 'NotesApp.db', location: 'default' },
  () => console.log('Database opened'),
  (error) => console.error('Error opening database', error)
)

export const initDatabase = () => {
  db.transaction((tx) => {
    tx.executeSql(
      'CREATE TABLE IF NOT EXISTS Notes (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, content TEXT, tags TEXT, created_at INTEGER, updated_at INTEGER)',
      [],
      () => console.log('Notes table created successfully'),
      (error) => console.error('Error creating Notes table', error)
    )
  })
}

export const noteService = {
  saveNote: (note) => {
    return new Promise((resolve, reject) => {
      db.transaction((tx) => {
        tx.executeSql(
          'INSERT INTO Notes (title, content, tags, created_at, updated_at) VALUES (?, ?, ?, ?, ?)',
          [note.title, note.content, JSON.stringify(note.tags), Date.now(), Date.now()],
          (_, result) => resolve(result.insertId),
          (_, error) => reject(error)
        )
      })
    })
  },

  getAllNotes: () => {
    return new Promise((resolve, reject) => {
      db.transaction((tx) => {
        tx.executeSql(
          'SELECT * FROM Notes ORDER BY updated_at DESC',
          [],
          (_, result) => resolve(result.rows.raw()),
          (_, error) => reject(error)
        )
      })
    })
  },

  updateNote: (note) => {
    return new Promise((resolve, reject) => {
      db.transaction((tx) => {
        tx.executeSql(
          'UPDATE Notes SET title = ?, content = ?, tags = ?, updated_at = ? WHERE id = ?',
          [note.title, note.content, JSON.stringify(note.tags), Date.now(), note.id],
          (_, result) => resolve(result.rowsAffected),
          (_, error) => reject(error)
        )
      })
    })
  },

  deleteNote: (noteId) => {
    return new Promise((resolve, reject) => {
      db.transaction((tx) => {
        tx.executeSql(
          'DELETE FROM Notes WHERE id = ?',
          [noteId],
          (_, result) => resolve(result.rowsAffected),
          (_, error) => reject(error)
        )
      })
    })
  },

  searchNotes: (query) => {
    return new Promise((resolve, reject) => {
      db.transaction((tx) => {
        tx.executeSql(
          'SELECT * FROM Notes WHERE title LIKE ? OR content LIKE ? ORDER BY updated_at DESC',
          [`%${query}%`, `%${query}%`],
          (_, result) => resolve(result.rows.raw()),
          (_, error) => reject(error)
        )
      })
    })
  },
}

This SQLite implementation provides more advanced features like searching and handling structured note data.

Combining AsyncStorage and SQLite

For a hybrid approach, we can use AsyncStorage for user preferences and settings, while using SQLite for the notes themselves. Here's an example of how to combine both:

import AsyncStorage from '@react-native-async-storage/async-storage'
import SQLite from 'react-native-sqlite-storage'

const db = SQLite.openDatabase(
  { name: 'NotesApp.db', location: 'default' },
  () => console.log('Database opened'),
  (error) => console.error('Error opening database', error)
)

const USER_PREFS_KEY = '@MyNotesApp_userPrefs'

export const userPrefsService = {
  savePrefs: async (prefs) => {
    try {
      await AsyncStorage.setItem(USER_PREFS_KEY, JSON.stringify(prefs))
    } catch (error) {
      console.error('Error saving user preferences', error)
    }
  },

  getPrefs: async () => {
    try {
      const prefs = await AsyncStorage.getItem(USER_PREFS_KEY)
      return prefs ? JSON.parse(prefs) : {}
    } catch (error) {
      console.error('Error retrieving user preferences', error)
      return {}
    }
  },
}

export const noteService = {
  // ... SQLite note operations (same as previous example)
}

In this combined approach, we use AsyncStorage for quickly accessing and updating user preferences, while leveraging SQLite for more complex note data management.

Handling Rich Text and Images in Notes

For a feature-rich notes app, we need to handle both rich text and images. Let's extend our SQLite implementation to support these features:

  1. Install necessary packages:
npm install react-native-fs
  1. Update the database schema to include a column for image paths:
export const initDatabase = () => {
  db.transaction((tx) => {
    tx.executeSql(
      'CREATE TABLE IF NOT EXISTS Notes (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, content TEXT, tags TEXT, image_paths TEXT, created_at INTEGER, updated_at INTEGER)',
      [],
      () => console.log('Notes table created successfully'),
      (error) => console.error('Error creating Notes table', error)
    )
  })
}
  1. Create a helper function to save images:
import RNFS from 'react-native-fs'

const IMAGE_DIR = `${RNFS.DocumentDirectoryPath}/note_images`

export const imageHelper = {
  saveImage: async (uri) => {
    try {
      await RNFS.mkdir(IMAGE_DIR)
      const fileName = `${Date.now()}.jpg`
      const filePath = `${IMAGE_DIR}/${fileName}`
      await RNFS.copyFile(uri, filePath)
      return filePath
    } catch (error) {
      console.error('Error saving image', error)
      return null
    }
  },

  deleteImage: async (filePath) => {
    try {
      await RNFS.unlink(filePath)
    } catch (error) {
      console.error('Error deleting image', error)
    }
  },
}
  1. Update the note service to handle rich text and images:
export const noteService = {
  saveNote: async (note) => {
    const imagePaths = []
    if (note.images) {
      for (const imageUri of note.images) {
        const savedPath = await imageHelper.saveImage(imageUri)
        if (savedPath) {
          imagePaths.push(savedPath)
        }
      }
    }

    return new Promise((resolve, reject) => {
      db.transaction((tx) => {
        tx.executeSql(
          'INSERT INTO Notes (title, content, tags, image_paths, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
          [
            note.title,
            note.content,
            JSON.stringify(note.tags),
            JSON.stringify(imagePaths),
            Date.now(),
            Date.now(),
          ],
          (_, result) => resolve(result.insertId),
          (_, error) => reject(error)
        )
      })
    })
  },

  updateNote: async (note) => {
    const existingNote = await this.getNoteById(note.id)
    const existingImagePaths = JSON.parse(existingNote.image_paths || '[]')
    const newImagePaths = []

    // Handle removed images
    for (const path of existingImagePaths) {
      if (!note.images.includes(path)) {
        await imageHelper.deleteImage(path)
      } else {
        newImagePaths.push(path)
      }
    }

    // Handle new images
    for (const imageUri of note.images) {
      if (!existingImagePaths.includes(imageUri)) {
        const savedPath = await imageHelper.saveImage(imageUri)
        if (savedPath) {
          newImagePaths.push(savedPath)
        }
      }
    }

    return new Promise((resolve, reject) => {
      db.transaction((tx) => {
        tx.executeSql(
          'UPDATE Notes SET title = ?, content = ?, tags = ?, image_paths = ?, updated_at = ? WHERE id = ?',
          [
            note.title,
            note.content,
            JSON.stringify(note.tags),
            JSON.stringify(newImagePaths),
            Date.now(),
            note.id,
          ],
          (_, result) => resolve(result.rowsAffected),
          (_, error) => reject(error)
        )
      })
    })
  },

  deleteNote: async (noteId) => {
    const note = await this.getNoteById(noteId)
    const imagePaths = JSON.parse(note.image_paths || '[]')

    // Delete associated images
    for (const path of imagePaths) {
      await imageHelper.deleteImage(path)
    }

    return new Promise((resolve, reject) => {
      db.transaction((tx) => {
        tx.executeSql(
          'DELETE FROM Notes WHERE id = ?',
          [noteId],
          (_, result) => resolve(result.rowsAffected),
          (_, error) => reject(error)
        )
      })
    })
  },

  getNoteById: (noteId) => {
    return new Promise((resolve, reject) => {
      db.transaction((tx) => {
        tx.executeSql(
          'SELECT * FROM Notes WHERE id = ?',
          [noteId],
          (_, result) => resolve(result.rows.item(0)),
          (_, error) => reject(error)
        )
      })
    })
  },
}

This updated implementation now handles both rich text content and images associated with notes.

Syncing Notes with the Cloud

To make our offline-first notes app truly useful, we need to implement a syncing mechanism with a cloud server. Here's a basic implementation of a sync service:

import { noteService } from './noteService'

const API_URL = 'https://api.example.com/notes'

export const syncService = {
  syncNotes: async () => {
    try {
      const localNotes = await noteService.getAllNotes()
      const response = await fetch(`${API_URL}/sync`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(localNotes),
      })

      if (!response.ok) {
        throw new Error('Sync failed')
      }

      const { updatedNotes, deletedNoteIds } = await response.json()

      // Update local database with changes from server
      for (const note of updatedNotes) {
        await noteService.updateNote(note)
      }

      for (const noteId of deletedNoteIds) {
        await noteService.deleteNote(noteId)
      }

      console.log('Sync completed successfully')
    } catch (error) {
      console.error('Error during sync', error)
    }
  },

  uploadImage: async (localPath) => {
    try {
      const formData = new FormData()
      formData.append('image', {
        uri: localPath,
        type: 'image/jpeg',
        name: 'image.jpg',
      })

      const response = await fetch(`${API_URL}/upload-image`, {
        method: 'POST',
        body: formData,
      })

      if (!response.ok) {
        throw new Error('Image upload failed')
      }

      const { cloudPath } = await response.json()
      return cloudPath
    } catch (error) {
      console.error('Error uploading image', error)
      return null
    }
  },
}

This sync service does the following:

  1. Sends all local notes to the server for synchronization.
  2. Receives updated notes and deleted note IDs from the server.
  3. Updates the local database with the changes from the server.
  4. Provides a method to upload images to the cloud.

To use this sync service effectively, you should:

  1. Call syncService.syncNotes() when the app comes online after being offline.
  2. Implement a background sync mechanism that periodically syncs notes when the app is online.
  3. Use syncService.uploadImage() when saving or updating notes with new images.

Edge Cases and Considerations for a Notes App

When building an offline-first notes app, consider the following edge cases and implement appropriate solutions:

  1. Conflict Resolution:

    • Scenario: A note is edited both locally and on the server while offline.
    • Solution: Implement a conflict resolution strategy, such as:
      • Using timestamps to determine the most recent version.
      • Presenting both versions to the user and letting them choose.
      • Automatically merging changes if possible.
  2. Large Data Sets:

    • Scenario: The user has thousands of notes.
    • Solution:
      • Implement pagination in your SQLite queries.
      • Use lazy loading when displaying notes in the UI.
  3. Limited Storage:

    • Scenario: The device is running out of storage.
    • Solution:
      • Implement a storage management system that alerts users when storage is low.
      • Offer options to delete old or large notes, or move them to cloud-only storage.
  4. Image Handling:

    • Scenario: The user adds many high-resolution images to their notes.
    • Solution:
      • Compress images before storing them.
      • Implement a maximum image size limit.
      • Offer cloud-only storage for images in low-storage situations.
  5. Network Changes:

    • Scenario: The network connection is unstable or switches between Wi-Fi and cellular.
    • Solution:
      • Implement robust error handling in the sync process.
      • Use a library like NetInfo to monitor network changes and adjust sync behavior accordingly.
  6. Sync Failures:

    • Scenario: A sync operation fails midway.
    • Solution:
      • Implement a retry mechanism with exponential backoff.
      • Keep track of sync status for each note to resume partial syncs.
  7. Version Management:

    • Scenario: The app is updated with a new database schema.
    • Solution:
      • Implement a robust database migration system.
      • Handle schema changes gracefully, ensuring no data loss during updates.
  8. Security:

    • Scenario: The user has sensitive information in their notes.
    • Solution:
      • Implement local encryption for stored notes.
      • Use secure transmission protocols (HTTPS) for syncing.
      • Offer an option for end-to-end encryption for highly sensitive notes.
  9. Cross-Device Sync:

    • Scenario: The user accesses notes from multiple devices.
    • Solution:
      • Implement a robust server-side sync mechanism that can handle multiple devices updating the same note.
      • Use a unique device identifier to track changes from different devices.
  10. Offline Duration:

    • Scenario: The app has been offline for a very long time.
    • Solution:
      • Implement a sync strategy that can handle large amounts of changed data efficiently.
      • Consider using a diff algorithm to sync only the changes rather than entire notes.

Conclusion

Building an offline-first notes app with React Native requires careful consideration of data storage, synchronization, and edge cases. By using a combination of AsyncStorage for simple data and SQLite for complex note structures, we can create a robust and flexible system.

Key takeaways:

  1. Use AsyncStorage for simple, quick-access data like user preferences.
  2. Use SQLite for complex, queryable data structures like notes with rich text and images.
  3. Implement a sync service that can handle various network conditions and conflict scenarios.
  4. Consider edge cases and implement solutions for data conflicts, storage limitations, and cross-device synchronization.

Remember to thoroughly test your app under various network conditions and with different amounts of data to ensure it provides a seamless experience for your users, regardless of their connection status.

By following these principles and techniques, you can create a powerful, responsive notes app that works flawlessly both online and offline, providing value to your users in any situation.


Hope this has helped you.

If you need any help building Android, iOS or Web apps, please get in touch with us at people@keyworks.cloud