Do you prefer audio-visual learning? Watch the video guide!
Or jump straight into the code
Or run npx create-expo-app --example with-legend-state-supabase
to create a new app with this example.
Legend-State is a super fast all-in-one state and sync library that lets you write less code to make faster apps. Legend-State has four primary goals:
- As easy as possible to use.
- The fastest React state library.
- Fine-grained reactivity for minimal renders.
- Powerful sync and persistence (with Supabase support built in!)
And, to put the cherry on top, it works with Expo and React Native (via React Native Async Storage). This makes it a perfect match for building local-first mobile and web apps.
What is a Local-First Architecture?
In local-first software, "the availability of another computer should never prevent you from working" (via Martin Kleppmann). When you are offline, you can still read and write directly from/to a database on your device. You can trust the software to work offline, and you know that when you are connected to the internet, your data will be seamlessly synced and available on any of your devices running the app. When you're online, this architecture is well suited for "multiplayer" apps, as popularized by Figma.
To dig deeper into what local-first is and how it works, refer to the Expo docs.
How Legend-State makes it work
A primary goal of Legend-State is to make automatic persisting and syncing both easy and very robust, as it's meant to be used to power all storage and sync of complex apps.
Any changes made while offline are persisted between sessions to be retried whenever connected. To do this, the sync system subscribes to changes on an observable, then on change goes through a multi-step flow to ensure that changes are persisted and synced.
- Save the pending changes to local persistence.
- Save the changes to local persistence.
- Save the changes to remote persistence.
- On remote save, set any needed changes (like updated_at) back into the observable and local persistence.
- Clear the pending changes in local persistence.
Setting up the Project
To set up a new React Native project you can use the create-expo-app
utility. You can create a blank app or choose from different examples.
For this tutorial, go ahead and create a new blank Expo app:
_10npx create-expo-app@latest --template blank
Installing Dependencies
The main dependencies you need are Legend State and supabase-js. Additionally, to make things work for React Native, you will need React Native Async Storage and react-native-get-random-values (to generate uuids).
Install the required dependencies via expo install
:
_10npx expo install @legendapp/state@beta @supabase/supabase-js react-native-get-random-values @react-native-async-storage/async-storage
Configuring Supabase
If you don't have a Supabase project already, head over to database.new and create a new project.
Next, create a .env.local
file in the root of your project and add the following env vars. You can find these in your Supabase dashboard.
_10EXPO_PUBLIC_SUPABASE_URL=_10EXPO_PUBLIC_SUPABASE_ANON_KEY=
Next, set up a utils file to hold all the logic for interacting with Supabase, we'll call it utils/SupaLegend.ts
.
Configuring Legend-State
Legend-State is very versatile and allows you to choose different persistence and storage strategies. For this example, we'll use React Native Async Storage
for local persistence across platforms and supabase
for remote persistence.
Extend your utils/SupaLegend.ts
file with the following configuration:
syncedSupabase
is the Legend-State sync plugin for Supabase and adds some default configuration for usage with supabase-js.
Setting up the Database Schema
If you haven't alread, install the Supabase CLI and run supabase init
to initialize your project.
Next, create the initial database migration to set up the todos
table:
_10supabase migrations new init
This will create a new SQL migration file in the supabase/migrations
directory. Open it and add the following SQL code:
_34create table todos (_34 id uuid default gen_random_uuid() primary key,_34 counter bigint generated by default as identity,_34 text text,_34 done boolean default false,_34 created_at timestamptz default now(),_34 updated_at timestamptz default now(),_34 deleted boolean default false -- needed for soft deletes_34);_34_34-- Enable realtime_34alter_34 publication supabase_realtime add table todos;_34_34-- Legend-State helper to facilitate "Sync only diffs" (changesSince: 'last-sync') mode_34CREATE OR REPLACE FUNCTION handle_times()_34 RETURNS trigger AS_34 $$_34 BEGIN_34 IF (TG_OP = 'INSERT') THEN_34 NEW.created_at := now();_34 NEW.updated_at := now();_34 ELSEIF (TG_OP = 'UPDATE') THEN_34 NEW.created_at = OLD.created_at;_34 NEW.updated_at = now();_34 END IF;_34 RETURN NEW;_34 END;_34 $$ language plpgsql;_34_34CREATE TRIGGER handle_times_34 BEFORE INSERT OR UPDATE ON todos_34 FOR EACH ROW_34EXECUTE PROCEDURE handle_times();
The created_at
, updated_at
, and deleted
columns are used by Legend-State to track changes and sync efficiently. The handle_times
function is used to automatically set the created_at
and updated_at
columns when a new row is inserted or an existing row is updated. This allows to efficiently sync only the changes since the last sync.
Next, run supabase link
to link your local project to your Supabase project and run supabase db push
to apply the init migration to your Supabase database.
Generating TypeScript Types
Legend-State integrates with supabase-js to provide end-to-end type safety. This means you can use the existing Supabase CLI workflow to generate TypeScript types for your Supabase tables.
_10supabase start_10supabase gen types --lang=typescript --local > utils/database.types.ts
Next, in your utils/SupaLegend.ts
file, import the generated types inject them into the Supabase client.
From here, Legend-State will automatically infer the types for your Supabase tables and make them available within the observable.
Fetching Data and subscribing to realtime updates
Above, you've configured the todos$
observable. You can now import this in your tsx
files to fetch and automatically sync changes.
observer
is the suggested way of consuming observables for the best performance and safety.
It turns the entire component into an observing context - it automatically tracks observables for changes when get()
is called, even from within hooks or helper functions.
This means, as long as realtime is enabled on the respective table, the component will automatically update when changes are made to the data!
Also, thanks to the persist and retry settings above, Legend-State will automatically retry to sync changes if the connection is lost.
Inserting, and updating data
To add a new todo from the application, you will need to generate a uuid locally to insert it into our todos observable. You can use the uuid
package to generate a uuid. For this to work in React Native you will also need the react-native-get-random-values
polyfill.
In your SupaLegend.ts
file add the following:
Now, in your App.tsx
file, you can import the addTodo
and toggleDone
methods and call them when the user submits a new todo or checks off one:
Up next: Adding Auth
Since Legend-State utilizes supabase-js under the hood, you can use Supabase Auth and row level security to restrict access to the data.
For a tutorial on how to add user management to your Expo React Native application, refer to this guide.
Conclusion
Legend-State and Supabase are a powerful combination for building local-first applications. Legend-State pairs nicely with supabase-js, Supabase Auth and Supabase Realtime, allowing you to tap into the full power of the Supabase Stack while building fast and delightful applications that work across web and mobile platforms.
Want to learn more about Legend-State? Refer to their docs and make sure to follow Jay Meistrich on Twitter!