Using the Supabase JavaScript Client in a React Native app, it's super easy to fetch data from a Supabase backend by simply calling functions like supabase.from('countries').select()
wherever the data is needed. However, another approach to handling data in mobile apps recently gained a lot of popularity: Offline-first - meaning an app works primarily offline and synchronizes data whenever possible.
This has multiple advantages, the obvious being that the app can be used offline. But even when the app is used online, the user experience still profits from this approach as reading and updating data feels instantaneous as it happens on the device first and is then synced in the background. The approach also has drawbacks: Data can become stale, and if it is changed in multiple places while offline, data conflicts can occur.
There are sophisticated database technologies like CRDT (conflict-free replicated data type), which are also making their way into mobile apps. For our example, we will not reach for a CRDT and instead use WatermelonDB, an open-source solution built on SQLite that includes a sync engine and applies the simple strategy of just letting the latest change of a record win. Follow along to build a React Native/Expo app that uses WatermelonDB as the offline-first data store and syncs to a Supabase backend using Remote Procedure Calls and Supabase Realtime to trigger sync on other devices.
The approach described in this blog post was developed while building Share My Stack. This mobile app allows people to curate and share their personal productivity stack as well as their favorite development stack. The app is available on the App Store, and the complete source code is available on GitHub for educational purposes. Here is a little demo of what the result looks like when using the app on two separate devices:
For this tutorial, we'll be implementing a small part of the Share My Stack data model, namely the ability to have a "profile," which belongs to a "user" (stored in auth.users) and can have multiple "stacks" associated with it (see the output from the Supabase Schema Visualizer below for illustration). With that said, let's dive into building our local-first app ...
Create a React Native/Expo app
_10npx create-expo-app OfflineFirstWithSupabase
Add required dependencies
The first step is to add the required dependencies to your React Native project: WatermelonDB, Supabase, and expo-build-properties, which we'll use in the next step to customize the React Native app build.
_10npm install @nozbe/watermelondb @supabase/supabase-js expo-build-properties
Setting up WatermelonDB native dependencies
WatermelonDB is depending on simdjson, which needs to be added to the Podfile of the React Native project. Using expo-build-properties, this can be done with a simple configuration change without touching native iOS project files.
_21{_21 "expo": {_21 "plugins": [_21 [_21 "expo-build-properties",_21 {_21 "ios": {_21 "extraPods": [_21 {_21 "name": "simdjson",_21 "configurations": ["Debug", "Release"],_21 "path": "../node_modules/@nozbe/simdjson",_21 "modular_headers": true_21 }_21 ]_21 }_21 }_21 ]_21 ]_21 }_21}
Creating the WatermelonDB data model
WatermelonDB requires models to be created for the data that is stored and synced through it. Take a look at their docs for the details; here is an excerpt of the model used in Share My Stack, which represents a "profile" that has multiple "stacks" associated with it:
_20import { Model, Q, Relation } from "@nozbe/watermelondb";_20import { date, readonly, text } from "@nozbe/watermelondb/decorators";_20_20import { Stack } from "./Stack";_20_20export class Profile extends Model {_20static table = "profiles";_20_20@readonly @date("created_at") createdAt!: Date;_20@readonly @date("updated_at") updatedAt!: Date;_20_20static associations = {_20 ["stacks"]: {_20 type: "has_many" as const,_20 foreignKey: "profile_id",_20 }_20};_20_20@text("name") name!: string;_20@text("website") website!: string;
Syncing the local DB with Supabase
The WatermelonDB docs outline how to set up a sync function that talks with the backend server. In our case, the backend is a Postgres database hosted on Supabase. As indicated earlier, we'll implement the sync via two Postgres functions called via Supabase RPC. This way, each sync is only two database calls (one push, one pull), and most of the logic is carried out directly within the database.
_21import { SyncDatabaseChangeSet, synchronize } from '@nozbe/watermelondb/sync'_21_21await synchronize({_21 database,_21 pullChanges: async ({ lastPulledAt, schemaVersion, migration }) => {_21 const { data, error } = await supabase.rpc('pull', {_21 last_pulled_at: lastPulledAt,_21 })_21_21 const { changes, timestamp } = data as {_21 changes: SyncDatabaseChangeSet_21 timestamp: number_21 }_21_21 return { changes, timestamp }_21 },_21 pushChanges: async ({ changes, lastPulledAt }) => {_21 const { error } = await supabase.rpc('push', { changes })_21 },_21 sendCreatedAsUpdated: true,_21})
For the local synchronize function to be able to call Postgres functions (pull and push) via supabase.rpc(), those functions have to be created via migrations before. The specific implementation of those functions will be based on the schema of the data. The push function basically receives a set of data changes as a JSON object and writes them to the database, while the pull function receives the timestamp of the last sync and compiles a JSON object containing all changes the database has accumulated since this timestamps and returns it to the client.
Here is an example of a push function. This function implements the creation, deletion, and updating of profiles and would need to be extended for every additional object in the data model.
_27create or replace function push(changes jsonb) returns void as $$_27declare new_profile jsonb;_27declare updated_profile jsonb;_27begin_27-- create profiles_27for new_profile in_27select jsonb_array_elements((changes->'profiles'->'created')) loop perform create_profile(_27 (new_profile->>'id')::uuid,_27 (new_profile->>'user_id')::uuid,_27 (new_profile->>'name'),_27 (new_profile->>'website'),_27 epoch_to_timestamp(new_profile->>'created_at'),_27 epoch_to_timestamp(new_profile->>'updated_at')_27 );_27end loop;_27-- delete profiles_27with changes_data as (_27 select jsonb_array_elements_text(changes->'profiles'->'deleted')::uuid as deleted_27)_27-- update profiles_27update profiles_27set deleted_at = now(),_27 last_modified_at = now()_27from changes_data_27where profiles.id = changes_data.deleted;_27end;_27$$ language plpgsql;
And here is how the pull function might look like:
_51create or replace function pull(last_pulled_at bigint default 0) returns jsonb as $$_51declare _ts timestamp with time zone;_51_profiles jsonb;_51begin -- timestamp_51_ts := to_timestamp(last_pulled_at / 1000);_51--- profiles_51select jsonb_build_object(_51 'created',_51 '[]'::jsonb,_51 'updated',_51 coalesce(_51 jsonb_agg(_51 jsonb_build_object(_51 'id',_51 t.id,_51 'name',_51 t.name,_51 'website',_51 t.website,_51 'created_at',_51 timestamp_to_epoch(t.created_at),_51 'updated_at',_51 timestamp_to_epoch(t.updated_at)_51 )_51 ) filter (_51 where t.deleted_at is null_51 and t.last_modified_at > _ts_51 ),_51 '[]'::jsonb_51 ),_51 'deleted',_51 coalesce(_51 jsonb_agg(to_jsonb(t.id)) filter (_51 where t.deleted_at is not null_51 and t.last_modified_at > _ts_51 ),_51 '[]'::jsonb_51 )_51 ) into _profiles_51from sync_profiles_view t;_51return jsonb_build_object(_51 'changes',_51 jsonb_build_object(_51 'profiles',_51 _profiles_51 ),_51 'timestamp',_51 timestamp_to_epoch(now())_51);_51end;_51$$ language plpgsql;
To convert between timestamp formats used by WatermelonDB and Supabase/Postgres, the push and pull functions use the following utility functions in Supabase:
_12create or replace function epoch_to_timestamp(epoch text) returns timestamp with time zone as $$ begin return timestamp with time zone 'epoch' + ((epoch::bigint) / 1000) * interval '1 second';_12end;_12$$ language plpgsql;_12_12create or replace function timestamp_to_epoch(ts timestamp with time zone) returns bigint as $$ begin return (_12 extract(_12 epoch_12 from ts_12 ) * 1000_12 )::bigint;_12end;_12$$ language plpgsql;
Conclusion
I'm convinced local-first will be an increasingly important paradigm in the future - not for all apps, but at least for some. It can greatly enhance user experience with regards to reliability and performance and as we've seen in this tutorial, it doesn't need to be rocket science.
It does, however, come with its own set of trade-offs. One of the biggest drawbacks of the demonstrated approach, in my opinion, is the amount of friction it adds to changing the data model. Usually, when just adding the Supabase SDK, I add a new migration, re-generate the types, and use the data wherever I need to.
With the approach described above, I need to maintain the database schema in Supabase, and the local schema in WatermelonDB, and keep the sync functions up to date. In the end, for an app where the data model is not expected to change much and where very performant user interactions are key to adoption, this is still a very viable approach from my perspective.