Supabase plugin
Supabase and Legend-State work very well together - all you need to do is provide a typed client and the observables will be fully typed and handle calling the correct action functions for you.
Full Example
We’ll start with a full example to see what a full setup looks like, then go into specific details.
import { createClient } from '@supabase/supabase-js'import { Database } from './database.types'import { observable } from '@legendapp/state'import { configureSyncedSupabase, syncedSupabase } from '@legendapp/state/sync-plugins/supabase'import { v4 as uuidv4 } from "uuid"
const supabase = createClient<Database>(process.env.SUPABASE_URL, process.env.SUPABASE_ANON_KEY)
// provide a function to generate ids locallyconst generateId = () => uuidv4()configureSyncedSupabase({ generateId})const uid = ''
const messages$ = observable(syncedSupabase({ supabase, collection: 'messages', // Optional: // Select only id and text fields select: (from) => from.select('id,text'), // Filter by the current user filter: (select) => select.eq('user_id', uid), // Don't allow delete actions: ['read', 'create', 'update'], // Realtime filter by user_id realtime: { filter: `user_id=eq.${uid}` }, // Persist data and pending changes locally persist: { name: 'messages', retrySync: true }, // Sync only diffs changesSince: 'last-sync'}))
// get() activates and starts syncingconst messages = messages$.get()
function addMessage(text: string) { const id = generateId() // Add keyed by id to the messages$ observable to trigger a create in Supabase messages$[id].set({ id, text, created_at: null, updated_at: null })}
function updateMessage(id: string, text: string) { // Just set values in the observable to trigger an update to Supabase messages$[id].text.set(text)}
Set up Supabase types
The first step to getting strongly typed observables from Supabase is to follow their instructions to create a typed client.
https://supabase.com/docs/guides/api/rest/generating-types
The examples on this page will use the supabase
client from the generated types:
import { createClient } from '@supabase/supabase-js'import { Database } from './database.types'
const supabase = createClient<Database>(process.env.SUPABASE_URL, process.env.SUPABASE_ANON_KEY)
filter
By default it will use select()
on the collection. If you want to filter the data, use the filter
parameter. See the Using Filters docs for details.
const messages$ = observable(syncedSupabase({ supabase, collection: 'messages', // Filter by the current user filter: (select) => select.eq('user_id', 'uid')}))
select
By default it will use select()
on the collection. If you want to be more specific, use the select
parameter to customize how you want to select. See the Select docs for details.
You can also add filters here if you want.
const messages$ = observable(syncedSupabase({ supabase, collection: 'messages', // Select only id and text fields select: (from) => from.select('id,text'), // Or select and filter together select: (from) => from.select('id,text').eq('user_id', 'uid')}))
actions
By default it will support create, read, update, and delete. But you can specify which actions you want to support with the actions
parameter.
const messages$ = observable(syncedSupabase({ supabase, collection: 'messages', // Only read and create, no update or delete actions: ['read', 'create'],}))
as
The shape of the observable object can be changed with the as
parameter, which supports three options:
object
: The default, an object keyed by the row’sid
field.Map
: A Map, which can be more efficient for accessing rows by keyvalue
: Treat the result of a query as a single value like aget
Note that array
is not an option because arrays make it hard to to efficiently and correctly add, update, and remove elements by id.
const messages$ = observable(syncedSupabase({ supabase, collection: 'messages', as: 'Map'}))
// messages$ is an observable Mapmessages$.get('messageId').text.set('hello')
Realtime
Enable realtime on the observable with the realtime
option. This will update the observable immediately whenever any realtime changes come in. You can optionally set the schema
and filter
for the realtime listener.
See Supabase’s Realtime Docs for more details about realtime filters.
const messages$ = observable(syncedSupabase({ supabase, collection: 'messages', // Simply enable it realtime: true, // Or set options realtime: { schema: 'public', filter: `user_id=eq.${uid}`},}))
RPC and Edge Functions
You can override any or all of the default list/create/update/delete actions with an rpc or function call. There is just one requirement: create and update need to return either full row data or nothing, because the returned data is used to update the observable with any fields changed remotely (like updated_at).
One caveat is that Supabase’s edge functions are not strongly typed so the observable can’t infer the type from it.
const messages$ = observable(syncedSupabase({ supabase, collection: 'messages', // Simply enable it realtime: true, // Use an rpc function for listing list: () => supabase.rpc("list_messages"), // Use an rpc function for creating create: (input) => supabase.rpc("create_country", input), // Or use functions list: () => supabase.functions.invoke("list_messages"),}))
Sync only diffs
An optional but very useful feature is the changesSince: 'last-sync'
option. This can massively reduce badwidth usage when you’re persisting list results since it only needs to list changes since the last query. The way this works internally is basically:
- Save the maximum updatedAt to the local persistence
- In subsequent syncs or after refresh it will list by
updated_at: lastSync + 1
to get only recent changes - The new changes will be merged into the observable
Enabling this on the Supabase side requires adding created_at
and updated_at
columns and a trigger to your table. You can run this snippet to set it up, just replace the two instances of YOUR_TABLE_NAME.
-- Add new columns to table named `created_at` and `updated_at`ALTER TABLE YOUR_TABLE_NAMEADD COLUMN created_at timestamptz default now(),ADD COLUMN updated_at timestamptz default now(),-- Add column for soft deletes, remove this if you don't need thatADD COLUMN deleted boolean default false;
-- This will set the `created_at` column on create and `updated_at` column on every updateCREATE OR REPLACE FUNCTION handle_times() RETURNS trigger AS $$ BEGIN IF (TG_OP = 'INSERT') THEN NEW.created_at := now(); NEW.updated_at := now(); ELSEIF (TG_OP = 'UPDATE') THEN NEW.created_at = OLD.created_at; NEW.updated_at = now(); END IF; RETURN NEW; END; $$ language plpgsql;
CREATE TRIGGER handle_times BEFORE INSERT OR UPDATE ON YOUR_TABLE_NAME FOR EACH ROWEXECUTE PROCEDURE handle_times();
And to enable this feature in Legend-State, use the changesSince
option and set the fieldCreatedAt
and fieldUpdatedAt
options to match the Supabase column names.
// Sync diffs of a listsyncedSupabase({ supabase, collection: 'messages', persist: { name: 'messages' }, // Enable syncing only changes since last-sync changesSince: 'last-sync', fieldCreatedAt: 'created_at', fieldUpdatedAt: 'updated_at', // Optionally enable soft deletes fieldDeleted: 'deleted'})
// Or you can configure this optional globally so it will apply to every instance of syncedSupabase.configureSyncedSupabase({ changesSince: 'last-sync', fieldCreatedAt: 'created_at', fieldUpdatedAt: 'updated_at', // Optionally enable soft deletes fieldDeleted: 'deleted'})
Soft deletes
The delete parameter does not need to be an actual delete
action in Supabase. You could also implement it as a soft delete if you prefer, just setting a deleted
field to true. To do that you can provide fieldDeleted
matching the field name in your table.
Then when you delete an element it will internally update the row with { deleted: true }
instead of deleting it, and the list action will remove deleted elements from the observable.
// Sync diffs of a listsyncedSupabase({ supabase, collection: 'messages', fieldDeleted: 'deleted'})