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:

  1. As easy as possible to use.
  2. The fastest React state library.
  3. Fine-grained reactivity for minimal renders.
  4. 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.

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.

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.

  1. Save the pending changes to local persistence.
  2. Save the changes to local persistence.
  3. Save the changes to remote persistence.
  4. On remote save, set any needed changes (like updated_at) back into the observable and local persistence.
  5. Clear the pending changes in local persistence.

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:


_10

npx create-expo-app@latest --template blank


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:


_10

npx expo install @legendapp/state@beta @supabase/supabase-js react-native-get-random-values @react-native-async-storage/async-storage


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.


_10

EXPO_PUBLIC_SUPABASE_URL=

_10

EXPO_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.


_10

import { createClient } from '@supabase/supabase-js'

_10

_10

const supabase = createClient(

_10

process.env.EXPO_PUBLIC_SUPABASE_URL,

_10

process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY

_10

)


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:


_46

import { createClient } from '@supabase/supabase-js'

_46

import { observable } from '@legendapp/state'

_46

import { syncedSupabase } from '@legendapp/state/sync-plugins/supabase'

_46

import { configureSynced } from '@legendapp/state/sync'

_46

import { observablePersistAsyncStorage } from '@legendapp/state/persist-plugins/async-storage'

_46

import AsyncStorage from '@react-native-async-storage/async-storage'

_46

_46

const supabase = createClient(

_46

process.env.EXPO_PUBLIC_SUPABASE_URL,

_46

process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY

_46

)

_46

_46

// Create a configured sync function

_46

const customSynced = configureSynced(syncedSupabase, {

_46

// Use React Native Async Storage

_46

persist: {

_46

plugin: observablePersistAsyncStorage({

_46

AsyncStorage,

_46

}),

_46

},

_46

generateId,

_46

supabase,

_46

changesSince: 'last-sync',

_46

fieldCreatedAt: 'created_at',

_46

fieldUpdatedAt: 'updated_at',

_46

// Optionally enable soft deletes

_46

fieldDeleted: 'deleted',

_46

})

_46

_46

export const todos$ = observable(

_46

customSynced({

_46

supabase,

_46

collection: 'todos',

_46

select: (from) => from.select('id,counter,text,done,created_at,updated_at,deleted'),

_46

actions: ['read', 'create', 'update', 'delete'],

_46

realtime: true,

_46

// Persist data and pending changes locally

_46

persist: {

_46

name: 'todos',

_46

retrySync: true, // Persist pending changes and retry

_46

},

_46

retry: {

_46

infinite: true, // Retry changes with exponential backoff

_46

},

_46

})

_46

)


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:


_10

supabase migrations new init


This will create a new SQL migration file in the supabase/migrations directory. Open it and add the following SQL code:


_34

create 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

_34

alter

_34

publication supabase_realtime add table todos;

_34

_34

-- Legend-State helper to facilitate "Sync only diffs" (changesSince: 'last-sync') mode

_34

CREATE 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

_34

CREATE TRIGGER handle_times

_34

BEFORE INSERT OR UPDATE ON todos

_34

FOR EACH ROW

_34

EXECUTE 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.

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.


_10

supabase start

_10

supabase 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.


_10

import { createClient } from '@supabase/supabase-js'

_10

import { Database } from './database.types'

_10

// [...]

_10

_10

const supabase = createClient<Database>(

_10

process.env.EXPO_PUBLIC_SUPABASE_URL,

_10

process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY

_10

)

_10

_10

// [...]


From here, Legend-State will automatically infer the types for your Supabase tables and make them available within the observable.

Above, you've configured the todos$ observable. You can now import this in your tsx files to fetch and automatically sync changes.


_12

import { observer } from '@legendapp/state/react'

_12

import { todos$ as _todos$ } from './utils/SupaLegend'

_12

_12

const Todos = observer(({ todos$ }: { todos$: typeof _todos$ }) => {

_12

// Get the todos from the state and subscribe to updates

_12

const todos = todos$.get()

_12

const renderItem = ({ item: todo }: { item: Tables<'todos'> }) => <Todo todo={todo} />

_12

if (todos)

_12

return <FlatList data={Object.values(todos)} renderItem={renderItem} style={styles.todos} />

_12

_12

return <></>

_12

})


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.

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:


_20

// [...]

_20

import 'react-native-get-random-values'

_20

import { v4 as uuidv4 } from 'uuid'

_20

// [...]

_20

_20

// Provide a function to generate ids locally

_20

const generateId = () => uuidv4()

_20

_20

export function addTodo(text: string) {

_20

const id = generateId()

_20

// Add keyed by id to the todos$ observable to trigger a create in Supabase

_20

todos$[id].assign({

_20

id,

_20

text,

_20

})

_20

}

_20

_20

export function toggleDone(id: string) {

_20

todos$[id].done.set((prev) => !prev)

_20

}


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:


_46

import { useState } from 'react'

_46

import { FlatList, StyleSheet, Text, TextInput, TouchableOpacity } from 'react-native'

_46

// [...]

_46

import { observer } from '@legendapp/state/react'

_46

import { addTodo, todos$ as _todos$, toggleDone } from './utils/SupaLegend'

_46

// [...]

_46

_46

// Emojis to decorate each todo.

_46

const NOT_DONE_ICON = String.fromCodePoint(0x1f7e0)

_46

const DONE_ICON = String.fromCodePoint(0x2705)

_46

_46

// The text input component to add a new todo.

_46

const NewTodo = () => {

_46

const [text, setText] = useState('')

_46

const handleSubmitEditing = ({ nativeEvent: { text } }) => {

_46

setText('')

_46

addTodo(text)

_46

}

_46

return (

_46

<TextInput

_46

value={text}

_46

onChangeText={(text) => setText(text)}

_46

onSubmitEditing={handleSubmitEditing}

_46

placeholder="What do you want to do today?"

_46

style={styles.input}

_46

/>

_46

)

_46

}

_46

_46

// A single todo component, either 'not done' or 'done': press to toggle.

_46

const Todo = ({ todo }: { todo: Tables<'todos'> }) => {

_46

const handlePress = () => {

_46

toggleDone(todo.id)

_46

}

_46

return (

_46

<TouchableOpacity

_46

key={todo.id}

_46

onPress={handlePress}

_46

style={[styles.todo, todo.done ? styles.done : null]}

_46

>

_46

<Text style={styles.todoText}>

_46

{todo.done ? DONE_ICON : NOT_DONE_ICON} {todo.text}

_46

</Text>

_46

</TouchableOpacity>

_46

)

_46

}


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.

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!