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 locally
const 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 syncing
const 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 Map
messages$.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.
Requires soft deletes
It's not possible to list rows deleted in supabase to remove them from the local persistence, so you will have to use soft deletes described in the next session. If you don't need to delete you can remove that column from the script.
-- Add new columns to table named `created_at` and `updated_at`
ALTER TABLE YOUR_TABLE_NAME
ADD 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 that
ADD COLUMN deleted boolean default false;
-- This will set the `created_at` column on create and `updated_at` column on every update
CREATE 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 ROW
EXECUTE 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 list
syncedSupabase({
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 list
syncedSupabase({
supabase,
collection: 'messages',
fieldDeleted: 'deleted'
})