WellAlly Logo
WellAlly康心伴
Development

Offline-First Sleep Tracking with React Native, WatermelonDB, and Expo

Learn how to build a resilient sleep tracking app with React Native that works seamlessly without an internet connection. This guide covers setting up WatermelonDB for local data persistence and creating a robust synchronization strategy with Expo.

W
2025-12-12
9 min read

In a world where constant connectivity is the norm, what happens when it's not available? For an app designed to track something as personal and routine as sleep, reliability is paramount. Imagine waking up after a great night's sleep, ready to log the details, only to be met with a loading spinner because of a weak or non-existent internet connection. This is a frustrating user experience that can be avoided with an offline-first architecture.

In this tutorial, we'll build a sleep tracking app using React Native and Expo that prioritizes the user's ability to interact with their data at all times. We'll leverage the power of WatermelonDB, a high-performance reactive database framework, to store data locally on the device. We'll also implement a robust synchronization mechanism to push the locally saved data to a central server once an internet connection is available.

This approach not only enhances the user experience by making the app feel faster and more reliable but also reduces server dependency.

Prerequisites:

  • Basic understanding of React Native and Expo.
  • Node.js and npm/yarn installed.
  • An Expo Go account and the app installed on your device (or a simulator setup).
  • A backend to sync with (we'll provide a simple Node.js/Express example).

Understanding the Problem

Standard mobile applications often rely on a constant connection to a remote server for data operations. This can lead to a sluggish and unreliable user experience, especially in areas with poor connectivity. An offline-first approach flips this model by treating the local database as the primary source of truth.

Challenges with a naive offline approach:

  • Data conflicts: What happens if data is changed on multiple devices while offline?
  • Synchronization complexity: How do you efficiently sync local changes with a remote server without data loss?
  • Performance: How do you handle large amounts of local data without slowing down the app?

WatermelonDB is designed to tackle these challenges head-on. It's built on top of SQLite, providing a solid and performant foundation, and its synchronization primitives make it easier to reason about and implement data sync.

Prerequisites

Before we start, make sure you have the following tools installed and set up:

  • Node.js (LTS version): https://nodejs.org/
  • Expo CLI: npm install -g expo-cli
  • A new Expo project: expo init offline-sleep-tracker (choose a blank template)
  • A physical device with the Expo Go app or an emulator.

Step 1: Setting Up WatermelonDB with Expo

While WatermelonDB is a native module, we can easily integrate it into our managed Expo workflow thanks to a dedicated plugin.

What we're doing

We'll install WatermelonDB and its dependencies, then configure the Expo plugin to handle the native setup for us.

Implementation

  1. Install dependencies:

    code
    npx expo install @nozbe/watermelondb @babel/plugin-proposal-decorators
    npx expo install @morrowdigital/watermelondb-expo-plugin expo-build-properties
    
    Code collapsed
  2. Configure babel.config.js:

    Add the @babel/plugin-proposal-decorators plugin. Make sure it's the first plugin listed.

    code
    // babel.config.js
    module.exports = function (api) {
      api.cache(true);
      return {
        presets: ['babel-preset-expo'],
        plugins: [
          ['@babel/plugin-proposal-decorators', { 'legacy': true }],
          // ... other plugins
        ],
      };
    };
    
    Code collapsed
  3. Configure app.json:

    Add the WatermelonDB plugin to your app.json file.

    code
    // app.json
    {
      "expo": {
        // ... other settings
        "plugins": [
          "@morrowdigital/watermelondb-expo-plugin"
        ]
      }
    }
    
    Code collapsed

How it works

The @morrowdigital/watermelondb-expo-plugin automatically handles the native configuration required by WatermelonDB during the build process, allowing us to use it within a managed Expo project. The expo-build-properties plugin allows for further customization of native builds.

Step 2: Defining the Data Schema and Models

Now for the heart of our local database: the schema and models. We'll define the structure of our sleep data.

What we're doing

We'll create a schema that defines a sleep_sessions table and a corresponding SleepSession model to interact with it.

Implementation

  1. Create a db directory:

    Inside your project's root, create a db folder. This will house all our database-related files.

  2. Define the schema (db/schema.js):

    code
    // db/schema.js
    import { appSchema, tableSchema } from '@nozbe/watermelondb';
    
    export default appSchema({
      version: 1,
      tables: [
        tableSchema({
          name: 'sleep_sessions',
          columns: [
            { name: 'started_at', type: 'number' },
            { name: 'ended_at', type: 'number', isOptional: true },
            { name: 'quality', type: 'number', isOptional: true },
            { name: 'notes', type: 'string', isOptional: true },
            { name: 'created_at', type: 'number' },
            { name: 'updated_at', type: 'number' },
          ],
        }),
      ],
    });
    
    Code collapsed
  3. Create the model (db/SleepSession.js):

    code
    // db/SleepSession.js
    import { Model } from '@nozbe/watermelondb';
    import { field, readonly, date } from '@nozbe/watermelondb/decorators';
    
    export default class SleepSession extends Model {
      static table = 'sleep_sessions';
    
      @field('started_at') startedAt;
      @field('ended_at') endedAt;
      @field('quality') quality;
      @field('notes') notes;
    
      @readonly @date('created_at') createdAt;
      @readonly @date('updated_at') updatedAt;
    }
    
    Code collapsed

How it works

The schema defines the tables and their columns in our SQLite database. The model is a JavaScript class that maps to a table, allowing us to create, read, update, and delete records as if they were objects. The @field decorators link class properties to table columns.

Step 3: Initializing the Database

With our schema and model defined, let's initialize the database when our app starts.

What we're doing

We'll create a central entry point for our database, making it accessible throughout the app.

Implementation

  1. Create db/index.js:

    code
    // db/index.js
    import { Database } from '@nozbe/watermelondb';
    import SQLiteAdapter from '@nozbe/watermelondb/adapters/sqlite';
    
    import schema from './schema';
    import SleepSession from './SleepSession';
    
    const adapter = new SQLiteAdapter({
      schema,
    });
    
    export const database = new Database({
      adapter,
      modelClasses: [SleepSession],
    });
    
    Code collapsed

How it works

We create a SQLiteAdapter which tells WatermelonDB how to interact with the underlying SQLite database. We then instantiate the Database class, passing in our adapter and an array of our model classes.

Step 4: Building the UI and Interacting with the Database

Let's create a simple UI to track and display sleep sessions.

What we're doing

We'll build a React component that can create, display, and update sleep sessions. We'll use WatermelonDB's withObservables higher-order component to make our UI reactive to database changes.

Implementation

  1. Create a SleepTracker component:

    code
    // components/SleepTracker.js
    import React, { useState } from 'react';
    import { View, Text, Button, FlatList, TextInput } from 'react-native';
    import { withObservables } from '@nozbe/with-observables';
    import { database } from '../db';
    
    const SleepTracker = ({ sleepSessions }) => {
      const [isTracking, setIsTracking] = useState(false);
      const [currentSession, setCurrentSession] = useState(null);
      const [notes, setNotes] = useState('');
    
      const handleStartTracking = async () => {
        await database.write(async () => {
          const newSession = await database.get('sleep_sessions').create(session => {
            session.startedAt = new Date().getTime();
          });
          setCurrentSession(newSession);
          setIsTracking(true);
        });
      };
    
      const handleEndTracking = async () => {
        await database.write(async () => {
          await currentSession.update(session => {
            session.endedAt = new Date().getTime();
            session.notes = notes;
          });
          setIsTracking(false);
          setCurrentSession(null);
          setNotes('');
        });
      };
    
      const renderItem = ({ item }) => (
        <View style={{ padding: 10, borderBottomWidth: 1, borderColor: '#ccc' }}>
          <Text>Started: {new Date(item.startedAt).toLocaleString()}</Text>
          {item.endedAt && <Text>Ended: {new Date(item.endedAt).toLocaleString()}</Text>}
          <Text>Notes: {item.notes}</Text>
        </View>
      );
    
      return (
        <View>
          {!isTracking ? (
            <Button title="Start Sleep" onPress={handleStartTracking} />
          ) : (
            <View>
              <TextInput
                placeholder="Add notes..."
                value={notes}
                onChangeText={setNotes}
              />
              <Button title="End Sleep" onPress={handleEndTracking} />
            </View>
          )}
          <FlatList
            data={sleepSessions}
            renderItem={renderItem}
            keyExtractor={item => item.id}
          />
        </View>
      );
    };
    
    const enhance = withObservables([], () => ({
      sleepSessions: database.get('sleep_sessions').query().observe(),
    }));
    
    export default enhance(SleepTracker);
    
    Code collapsed
  2. Use the component in App.js:

    code
    // App.js
    import React from 'react';
    import { SafeAreaView, StyleSheet } from 'react-native';
    import SleepTracker from './components/SleepTracker';
    
    export default function App() {
      return (
        <SafeAreaView style={styles.container}>
          <SleepTracker />
        </SafeAreaView>
      );
    }
    
    const styles = StyleSheet.create({
      container: {
        flex: 1,
        marginTop: 50,
      },
    });
    
    Code collapsed

How it works

The withObservables HOC subscribes our component to changes in the sleep_sessions table. Whenever a new session is created or an existing one is updated, our component will automatically re-render with the latest data. All database modifications must be wrapped in a database.write() block.

Step 5: Implementing Synchronization

Now, let's get our offline data synced with a remote server.

What we're doing

We'll create a sync function that uses WatermelonDB's synchronize utility to pull changes from and push changes to a backend server.

Implementation

  1. Create a simple backend (e.g., in server/index.js):

    For this tutorial, we'll use a simple in-memory store. In a real application, you would use a persistent database like PostgreSQL or MongoDB.

    code
    // server/index.js
    const express = require('express');
    const bodyParser = require('body-parser');
    const app = express();
    const port = 3000;
    
    app.use(bodyParser.json());
    
    let sleepSessions = {};
    
    app.get('/sync', (req, res) => {
      const lastPulledAt = parseInt(req.query.last_pulled_at || '0');
      const changes = {
        sleep_sessions: {
          created: [],
          updated: Object.values(sleepSessions).filter(s => s.updated_at > lastPulledAt),
          deleted: [],
        },
      };
      res.json({ changes, timestamp: new Date().getTime() });
    });
    
    app.post('/sync', (req, res) => {
      const { changes } = req.body;
      const createdSessions = changes.sleep_sessions?.created || [];
      const updatedSessions = changes.sleep_sessions?.updated || [];
    
      createdSessions.forEach(session => {
        sleepSessions[session.id] = { ...session, updated_at: new Date().getTime() };
      });
      updatedSessions.forEach(session => {
        sleepSessions[session.id] = { ...session, updated_at: new Date().getTime() };
      });
    
      res.sendStatus(200);
    });
    
    app.listen(port, () => {
      console.log(`Sync server listening at http://localhost:${port}`);
    });
    
    Code collapsed
  2. Create the sync function in your app (db/sync.js):

    code
    // db/sync.js
    import { synchronize } from '@nozbe/watermelondb/sync';
    import { database } from './index';
    
    const SYNC_API_URL = 'http://<your-local-ip>:3000/sync';
    
    export async function sync() {
      await synchronize({
        database,
        pullChanges: async ({ lastPulledAt }) => {
          const response = await fetch(
            `${SYNC_API_URL}?last_pulled_at=${lastPulledAt || 0}`
          );
          if (!response.ok) {
            throw new Error(await response.text());
          }
          const { changes, timestamp } = await response.json();
          return { changes, timestamp };
        },
        pushChanges: async ({ changes, lastPulledAt }) => {
          const response = await fetch(
            `${SYNC_API_URL}?last_pulled_at=${lastPulledAt || 0}`,
            {
              method: 'POST',
              headers: {
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({ changes }),
            }
          );
          if (!response.ok) {
            throw new Error(await response.text());
          }
        },
      });
    }
    
    Code collapsed
  3. Trigger sync in your app:

    You can trigger the sync manually with a button, or automatically when the app detects an internet connection.

    code
    // In SleepTracker.js
    import { sync } from '../db/sync';
    
    // ... inside the component
    <Button title="Sync Data" onPress={sync} />
    
    Code collapsed

How it works

The synchronize function orchestrates a two-phase sync. First, pullChanges fetches all records from the server that have been created or updated since the lastPulledAt timestamp. Then, pushChanges sends all locally created, updated, or deleted records to the server. WatermelonDB automatically handles conflict resolution.

Conclusion

We've successfully built an offline-first sleep tracking application with React Native, Expo, and WatermelonDB. This architecture provides a superior user experience by ensuring the app is always functional and responsive, regardless of network conditions.

What we've achieved:

  • Set up WatermelonDB in a managed Expo project.
  • Defined a schema and models for our sleep tracking data.
  • Built a reactive UI that automatically updates with database changes.
  • Implemented a robust two-way synchronization with a remote server.

Next steps for you:

  • Add user authentication to the sync process.
  • Implement a more sophisticated backend with a persistent database.
  • Explore WatermelonDB's more advanced features, like relations.
  • Use a library like NetInfo to trigger sync automatically.

Resources

#

Article Tags

reactnative
mobile
database
tutorial
healthtech
W

WellAlly's core development team, comprised of healthcare professionals, software engineers, and UX designers committed to revolutionizing digital health management.

Expertise

Healthcare Technology
Software Development
User Experience
AI & Machine Learning

Found this article helpful?

Try KangXinBan and start your health management journey