Offline-First React Native Applications with WatermelonDB

Vinay BommaVinay Bomma
5 min read

Introduction

I'd been aware of WatermelonDB for a while but hadn't found the right project to try it on. While building Toki, my journaling app, I ran into the usual React Native storage limitations. I wanted proper offline functionality without writing complex sync logic myself.

WatermelonDB seemed like a good fit, it's designed for performance, works offline by default, and integrates well with Expo.

Initial Setup

Setting up WatermelonDB with Expo 52 and 53 is much simpler than it used to be. The updated expo-sqlite package works efficiently with JSI, and improvements to the Hermes engine make database queries noticeably faster. You no longer need complex native setup or ejecting from Expo.

We’ll get started by installing the necessary packages:

npm install @nozbe/watermelondb
npm install --save-dev @babel/plugin-proposal-decorators

We’ll need to update our babel configuration (babel.config.js) as well since WatermelonDB uses decorators.

module.exports = function(api) {
  api.cache(true);
  return {
    presets: ['babel-preset-expo'],
    plugins: [
      ['@babel/plugin-proposal-decorators', { legacy: true }],
    ],
  };
};

We’ll need to wrap our app with the database provider from WatermelonDB. The DatabaseProvider makes the database available throughout your component tree, similar to how React Context works:

// App.js
import React from 'react'
import { DatabaseProvider } from '@nozbe/watermelondb/react'
import { database } from './database/database'
import JournalScreen from './screens/JournalScreen'

export default function App() {
  return (
    <DatabaseProvider database={database}>
      <JournalScreen />
    </DatabaseProvider>
  )
}

Designing the Schema

For a journal app, the schema is straightforward: entries need an ID, text content, and creation date. I keep schemas simple initially and evolve them as needed.

// database/schema.js
import { appSchema, tableSchema } from '@nozbe/watermelondb'

export default appSchema({
  version: 1,
  tables: [
    tableSchema({
      name: 'entries',
      columns: [
        { name: 'text', type: 'string' },
        { name: 'created_at', type: 'number' },
      ]
    }),
  ]
})

The version field in the schema is important for migrations. When you need schema changes later, increment the version and WatermelonDB handles the migration process automatically.

Creating the Model

// models/Entry.js
import { Model } from '@nozbe/watermelondb'
import { date, text } from '@nozbe/watermelondb/decorators'

export default class Entry extends Model {
  static table = 'entries'

  @text('text') text
  @date('created_at') createdAt
}

WatermelonDB models use decorators to map JavaScript properties to SQLite columns. The @text and @date decorators handle type conversion automatically, the date decorator is particularly useful since it converts timestamps to proper JavaScript Date objects.

Connecting to the Database

// database/database.js
import { Database } from '@nozbe/watermelondb'
import SQLiteAdapter from '@nozbe/watermelondb/adapters/sqlite'

import schema from './schema'
import Entry from '../models/Entry'

const adapter = new SQLiteAdapter({
  schema,
  jsi: true,
})

export const database = new Database({
  adapter,
  modelClasses: [Entry],
})

The database connection is straightforward to set up. The jsi: true option enables JavaScript Interface for better performance on newer React Native versions. This makes a noticeable difference in query speed and overall responsiveness.

// services/journalService.js
import { database } from '../database/database'
import { Q } from '@nozbe/watermelondb'

// Create a new journal entry
export const createEntry = async (text) => {
  return await database.write(async () => {
    return await database.collections.get('entries').create(entry => {
      entry.text = text
    })
  })
}

// Get all entries
export const getAllEntries = async () => {
  return await database.collections
    .get('entries')
    .query(Q.sortBy('created_at', Q.desc))
    .fetch()
}

// Observe entries for realtime updates
export const observeEntries = () => {
  return database.collections
    .get('entries')
    .query(Q.sortBy('created_at', Q.desc))
    .observe()
}

The observe() method is where WatermelonDB shines. Instead of manually refreshing data when it changes, you observe queries and the UI updates automatically. Add, edit, or delete entries, and connected components update without additional code.

This removes a lot of manual state synchronization work and reduces bugs from stale data.

Creating a React Hook

// hooks/useJournal.js
import { useState, useEffect } from 'react'
import { observeEntries, createEntry, updateEntry, deleteEntry } from '../services/journalService'

export const useJournal = () => {
  const [entries, setEntries] = useState([])
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    const subscription = observeEntries().subscribe(entries => {
      setEntries(entries)
      setLoading(false)
    })

    return () => subscription.unsubscribe()
  }, [])

  const addEntry = async (text) => {
    try {
      await createEntry(text)
      // No need to manually update state - the observer handles it!
    } catch (error) {
      console.error('Failed to add entry:', error)
    }
  }

  return {
    entries,
    loading,
    addEntry,
  }
}

This hook automatically subscribes to database changes when the component mounts and cleans up when it unmounts. Your components will always have the latest data without manual refreshing.

Beyond the Basics

WatermelonDB has solid patterns for syncing with backend services. Whether you're using Supabase, Firebase, or a custom API, there are established approaches for handling two-way sync, conflict resolution, and offline-first behavior. Here’s a link to an article on how to sync with Supabase.

Conclusion

WatermelonDB proved to be the right choice for this project. The setup is more involved than AsyncStorage, but the benefits in performance and functionality are significant. Features that would take days with manual state management now take hours to implement.

If you're building a React Native app with substantial data storage needs or offline requirements, WatermelonDB is worth considering. The integration with modern Expo removes previous setup barriers, and the reactive query system handles much of the complexity that typically comes with mobile database management.

It's a tool that handles the hard parts of data management while staying out of your way during development.

Thank You For Reading 👍

Check out my other articles or reach out to me at itsvinaybomma@gmail.com if you have any questions.

0
Subscribe to my newsletter

Read articles from Vinay Bomma directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Vinay Bomma
Vinay Bomma