Available Plugins
syncedKeel
Sync with Keel backend:import { syncedKeel } from '@legendapp/state/sync-plugins/keel'
import { observable } from '@legendapp/state'
const users$ = observable(syncedKeel({
list: queries.listUsers,
create: mutations.createUser,
update: mutations.updateUser,
delete: mutations.deleteUser,
persist: { name: 'users', retrySync: true },
changesSince: 'last-sync'
}))
syncedSupabase
Sync with Supabase PostgreSQL database:import { syncedSupabase } from '@legendapp/state/sync-plugins/supabase'
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(url, key)
const posts$ = observable(syncedSupabase({
supabase,
collection: 'posts',
filter: (select) => select.eq('published', true),
realtime: true,
persist: { name: 'posts', retrySync: true }
}))
syncedFirebase
Sync with Firebase Realtime Database:import { syncedFirebase } from '@legendapp/state/sync-plugins/firebase'
const data$ = observable(syncedFirebase({
refPath: (uid) => `users/${uid}/data`,
realtime: true,
fieldId: 'id',
persist: { name: 'userData', retrySync: true }
}))
syncedCrud
Generic CRUD operations for custom backends:import { syncedCrud } from '@legendapp/state/sync-plugins/crud'
const items$ = observable(syncedCrud({
list: async () => {
const res = await fetch('/api/items')
return res.json()
},
create: async (item) => {
const res = await fetch('/api/items', {
method: 'POST',
body: JSON.stringify(item)
})
return res.json()
},
update: async (item) => {
const res = await fetch(`/api/items/${item.id}`, {
method: 'PUT',
body: JSON.stringify(item)
})
return res.json()
},
delete: async (item) => {
await fetch(`/api/items/${item.id}`, { method: 'DELETE' })
},
as: 'object',
persist: { name: 'items', retrySync: true }
}))
Keel Plugin
Basic Setup
import { syncedKeel } from '@legendapp/state/sync-plugins/keel'
import { queries, mutations } from './keelClient'
interface User {
id: string
name: string
email: string
createdAt: Date
updatedAt: Date
}
const users$ = observable(syncedKeel<User>({
list: queries.listUsers,
create: mutations.createUser,
update: mutations.updateUser,
delete: mutations.deleteUser,
client: keelClient, // Optional: Keel client instance
persist: { name: 'users', retrySync: true },
changesSince: 'last-sync'
}))
Configuration Options
syncedKeel({
// CRUD operations
list: queries.listUsers, // Query to list items
create: mutations.createUser, // Create mutation
update: mutations.updateUser, // Update mutation
delete: mutations.deleteUser, // Delete mutation
// Keel client
client: keelClient,
// Query filtering
where: { status: 'active' }, // Filter query
first: 100, // Limit results
// Auth
requireAuth: true, // Wait for authentication
refreshAuth: async () => {
// Custom auth refresh logic
},
// Realtime updates
realtime: {
plugin: keelRealtimePlugin,
path: (action, inputs) => `/realtime/${action}`
},
// Data shape
as: 'object', // 'object' | 'Map' | 'array'
// Sync behavior
changesSince: 'last-sync',
persist: { name: 'users', retrySync: true },
retry: { infinite: true }
})
Keel-Specific Features
Automatic Field Mapping
Keel objects have built-in timestamp fields:interface KeelObject {
id: string
createdAt: Date
updatedAt: Date
}
changesSince: 'last-sync'.
Dynamic Filtering
const filter$ = observable({ status: 'active' })
const users$ = observable(syncedKeel({
list: queries.listUsers,
where: () => filter$.get(), // Dynamic filter
persist: { name: 'users' }
}))
// Update filter triggers re-fetch
filter$.status.set('inactive')
Authentication
syncedKeel({
client: keelClient,
requireAuth: true, // Don't sync until authenticated
refreshAuth: async () => {
// Refresh auth token
await keelClient.auth.refresh()
}
})
Supabase Plugin
Basic Setup
import { syncedSupabase } from '@legendapp/state/sync-plugins/supabase'
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
process.env.SUPABASE_URL,
process.env.SUPABASE_KEY
)
const posts$ = observable(syncedSupabase({
supabase,
collection: 'posts',
persist: { name: 'posts', retrySync: true }
}))
Configuration Options
syncedSupabase({
// Required
supabase: supabaseClient,
collection: 'posts',
// Query options
select: (query) => query.select('id, title, content, author(name)'),
filter: (select, params) => {
return select
.eq('published', true)
.order('created_at', { ascending: false })
},
schema: 'public', // Database schema
// Realtime subscriptions
realtime: true,
// Or configure:
realtime: {
schema: 'public',
filter: 'user_id=eq.123'
},
// CRUD actions (optional, auto-generated if not provided)
actions: ['create', 'read', 'update', 'delete'],
// Custom CRUD functions
list: async (params) => {
return supabase.from('posts').select()
},
create: async (item, params) => {
return supabase.from('posts').insert(item).select()
},
// Data shape
as: 'object',
// Timestamps
fieldCreatedAt: 'created_at',
fieldUpdatedAt: 'updated_at',
fieldDeleted: 'deleted_at', // Soft delete field
// Transform dates
stringifyDates: true,
// Sync behavior
changesSince: 'last-sync',
persist: { name: 'posts', retrySync: true }
})
Supabase-Specific Features
Realtime Subscriptions
Automatic updates from database changes:const messages$ = observable(syncedSupabase({
supabase,
collection: 'messages',
realtime: true, // Subscribe to changes
filter: (select) => select.eq('room_id', roomId),
persist: { name: 'messages' }
}))
// messages$ automatically updates when database changes
Custom Select with Joins
const posts$ = observable(syncedSupabase({
supabase,
collection: 'posts',
select: (query) => query.select(`
id,
title,
content,
author:profiles(name, avatar),
comments(id, text)
`)
}))
Row Level Security
Supabase RLS automatically enforced:// Only fetches rows user has access to
const userPosts$ = observable(syncedSupabase({
supabase, // Authenticated client
collection: 'posts',
filter: (select) => select.eq('user_id', userId)
}))
Soft Deletes
const posts$ = observable(syncedSupabase({
supabase,
collection: 'posts',
fieldDeleted: 'deleted_at', // Uses UPDATE instead of DELETE
changesSince: 'last-sync'
}))
// Sets deleted_at timestamp instead of deleting row
delete posts$['post-123']
Firebase Plugin
Basic Setup
import { syncedFirebase } from '@legendapp/state/sync-plugins/firebase'
import { getAuth } from 'firebase/auth'
const userData$ = observable(syncedFirebase({
refPath: (uid) => `users/${uid}/data`,
realtime: true,
requireAuth: true,
persist: { name: 'userData', retrySync: true }
}))
Configuration Options
syncedFirebase({
// Required: Path to Firebase data
refPath: (uid) => `users/${uid}/todos`,
// Optional: Custom query
query: (ref) => orderByChild(ref, 'priority'),
// Data structure
as: 'object', // 'object' | 'Map' | 'array' | 'value'
fieldId: 'id',
// Realtime updates
realtime: true,
// Auth
requireAuth: true,
// Read-only mode
readonly: false,
// Field transformations
fieldTransforms: {
createdAt: 'created_at',
updatedAt: 'updated_at'
},
// Timestamps
fieldCreatedAt: 'createdAt',
fieldUpdatedAt: 'updatedAt',
// Sync behavior
changesSince: 'last-sync',
persist: { name: 'todos', retrySync: true }
})
Firebase-Specific Features
Server Timestamps
import { serverTimestamp } from 'firebase/database'
const todos$ = observable(syncedFirebase({
refPath: (uid) => `users/${uid}/todos`,
fieldUpdatedAt: 'updatedAt',
realtime: true
}))
// Server timestamp automatically added
todos$['todo-1'].set({ text: 'Buy milk', updatedAt: serverTimestamp() })
Authentication Integration
import { onAuthStateChanged, getAuth } from 'firebase/auth'
// Automatically switches user data
const userData$ = observable(syncedFirebase({
refPath: (uid) => `users/${uid}/data`,
requireAuth: true // Waits for authentication
}))
// refPath automatically receives current user's UID
Queries
import { orderByChild, startAt, limitToFirst } from 'firebase/database'
const topPosts$ = observable(syncedFirebase({
refPath: () => 'posts',
query: (ref) => {
return query(
ref,
orderByChild('score'),
startAt(100),
limitToFirst(10)
)
},
as: 'array'
}))
CRUD Plugin
Generic plugin for custom REST APIs:Basic Setup
import { syncedCrud } from '@legendapp/state/sync-plugins/crud'
interface Todo {
id: string
text: string
completed: boolean
}
const todos$ = observable(syncedCrud<Todo>({
list: async () => {
const res = await fetch('/api/todos')
return res.json()
},
create: async (todo) => {
const res = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(todo)
})
return res.json()
},
update: async (todo) => {
const res = await fetch(`/api/todos/${todo.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(todo)
})
return res.json()
},
delete: async (todo) => {
await fetch(`/api/todos/${todo.id}`, { method: 'DELETE' })
},
as: 'object',
persist: { name: 'todos', retrySync: true }
}))
Configuration Options
syncedCrud({
// Read operations
list: async (params) => Todo[], // Fetch all items
get: async (params) => Todo, // Fetch single item (as: 'value')
// Write operations
create: async (item, params) => Todo,
update: async (item, params) => Todo,
delete: async (item, params) => void,
// Data structure
as: 'object', // 'object' | 'Map' | 'array' | 'value'
// ID field
fieldId: 'id',
generateId: () => crypto.randomUUID(),
// Timestamps for incremental sync
fieldCreatedAt: 'createdAt',
fieldUpdatedAt: 'updatedAt',
fieldDeleted: 'isDeleted', // Soft delete
// Partial updates
updatePartial: true, // Send only changed fields
// Incremental sync
changesSince: 'last-sync',
// Subscriptions
subscribe: (params) => {
// Set up realtime updates
return () => {} // Cleanup
},
// Lifecycle
onSaved: ({ saved, input, currentValue, isCreate }) => {
// Called after successful save
},
// Sync behavior
persist: { name: 'items', retrySync: true },
retry: { infinite: true },
debounceSet: 500
})
CRUD Features
Automatic CRUD Detection
The plugin automatically detects create vs update:const todos$ = observable(syncedCrud({
create: async (todo) => createTodo(todo),
update: async (todo) => updateTodo(todo)
}))
// Automatically calls create (no createdAt field)
todos$['new-id'].set({ text: 'New todo' })
// Automatically calls update (has createdAt)
todos$['existing-id'].text.set('Updated')
Partial Updates
const users$ = observable(syncedCrud({
updatePartial: true, // Only send changed fields
update: async (changes) => {
// changes only contains modified fields
return updateUser(changes)
}
}))
// Only sends { name: 'Alice' }
users$['user-123'].name.set('Alice')
Response Merging
const items$ = observable(syncedCrud({
create: async (item) => {
const response = await api.create(item)
// Server may add fields like timestamps
return response // { ...item, createdAt, updatedAt }
},
onSaved: ({ saved, input, currentValue }) => {
// saved: server response
// input: what was sent
// currentValue: current local value
// Can transform before merging
return { ...saved, localField: 'value' }
}
}))
Data Shapes
Theas option controls the data structure:
As Object (default)
const items$ = observable(syncedCrud({
as: 'object',
list: async () => [{ id: '1', name: 'A' }, { id: '2', name: 'B' }]
}))
// Stored as: { '1': { id: '1', name: 'A' }, '2': { id: '2', name: 'B' } }
items$['1'].name.get() // 'A'
As Map
const items$ = observable(syncedCrud({
as: 'Map',
list: async () => [{ id: '1', name: 'A' }]
}))
// Stored as: Map { '1' => { id: '1', name: 'A' } }
items$.get().get('1') // { id: '1', name: 'A' }
As Array
const items$ = observable(syncedCrud({
as: 'array',
list: async () => [{ id: '1', name: 'A' }]
}))
// Stored as: [{ id: '1', name: 'A' }]
items$[0].name.get() // 'A'
As Value
const user$ = observable(syncedCrud({
as: 'value',
get: async () => ({ id: '1', name: 'Alice' })
}))
// Stored as single value
user$.name.get() // 'Alice'
Fetch Plugin
Simple fetch-based sync for GET requests:import { syncedFetch } from '@legendapp/state/sync-plugins/fetch'
const data$ = observable(syncedFetch({
get: 'https://api.example.com/data',
persist: { name: 'apiData' }
}))
TanStack Query Plugin
Integration with TanStack Query (React Query):import { syncedTanstack } from '@legendapp/state/sync-plugins/tanstack-query'
import { useQuery } from '@tanstack/react-query'
const data$ = observable(syncedTanstack({
queryKey: ['todos'],
queryFn: async () => {
const res = await fetch('/api/todos')
return res.json()
},
persist: { name: 'todos' }
}))
Common Patterns
Incremental Sync
Only fetch changes since last sync:const data$ = observable(syncedCrud({
changesSince: 'last-sync',
fieldUpdatedAt: 'updatedAt',
list: async ({ lastSync }) => {
const url = lastSync
? `/api/items?since=${lastSync}`
: '/api/items'
const res = await fetch(url)
return res.json()
},
mode: 'merge' // Merge new data with existing
}))
Authenticated Requests
const authToken$ = observable('')
const data$ = observable(syncedCrud({
waitFor: () => authToken$.get(), // Wait for auth
list: async () => {
const res = await fetch('/api/data', {
headers: { Authorization: `Bearer ${authToken$.get()}` }
})
return res.json()
}
}))
Pagination
const page$ = observable(1)
const items$ = observable(syncedCrud({
list: async () => {
const res = await fetch(`/api/items?page=${page$.get()}`)
return res.json()
}
}))
// Load next page
page$.set(p => p + 1)
Real-time Updates
const messages$ = observable(syncedCrud({
list: async () => fetchMessages(),
subscribe: ({ update }) => {
const ws = new WebSocket('wss://api.example.com')
ws.onmessage = (event) => {
const message = JSON.parse(event.data)
update({ value: [message], mode: 'append' })
}
return () => ws.close()
}
}))
See Also
- synced() Function - Core sync API
- Local Persistence - Cache configuration
- Conflict Resolution - Handle conflicts