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
-
Install dependencies:
codenpx expo install @nozbe/watermelondb @babel/plugin-proposal-decorators npx expo install @morrowdigital/watermelondb-expo-plugin expo-build-propertiesCode collapsed -
Configure
babel.config.js:Add the
@babel/plugin-proposal-decoratorsplugin. 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 -
Configure
app.json:Add the WatermelonDB plugin to your
app.jsonfile.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
-
Create a
dbdirectory:Inside your project's root, create a
dbfolder. This will house all our database-related files. -
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 -
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
-
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
-
Create a
SleepTrackercomponent: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 -
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
-
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 -
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 -
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
NetInfoto trigger sync automatically.
Resources
- WatermelonDB Documentation: https://watermelondb.dev/
- WatermelonDB Expo Plugin: https://github.com/morrowdigital/watermelondb-expo-plugin
- Expo Documentation: https://docs.expo.dev/