Crud plugin
Legend-State includes a syncedCrud plugin that runs on top of synced and encapsulates a lot of the behavior you'd use to sync with a CRUD backend.
You can use syncedCrud directly or you can build a plugin for your backend on top of it. See the source of the Keel and Supabase plugins for examples of plugins built on top of syncedCrud.
get and list
The crud plugin has two slightly different patterns depending on whether you're using a get or a list action.
The behavior when using get is:
- get: Observable value is the value returned from get
- create: If get returned null, then setting any value on the observable will create
- update: If get returned a value, then updating any value on the observable will update
- delete: Setting the value to null or undefined, or calling
delete(), will delete
import { observable } from "@legendapp/state";
import { syncedCrud } from '@legendapp/state/sync-plugins/crud'
const profile$ = observable(syncedCrud({
get: getProfile,
create: createProfile,
update: updateProfile,
delete: deleteProfile,
}))
profile$.get() // => ProfileThe behavior when using list is:
- list: Observable value is an object containing the listed values keyed by id
- create: Adding a new value to the object will create
- update: Updating a child value will update it with the changed fields
- delete: Setting a child value to null or undefined, or calling
delete(), will delete
import { observable } from "@legendapp/state";
import { syncedCrud } from "@legendapp/state/sync-plugins/crud";
const profiles$ = observable(syncedCrud({
list: listProfiles,
create: createProfile,
update: updateProfile,
delete: deleteProfile,
}))
profiles$.get() // => Record<string, Profile>The list function expects an array of rows to be returned from your API.
The shape of the observable object returned from a list can be changed with the as parameter, which supports four options:
object: The default, an object keyed by the row'sidfield.array: Treat the result of a query as an arrayMap: A Map, which can be more efficient for accessing rows by keyvalue: Treat the result of a query as a single value like aget
create
The create function is called whenever a new object is added to the observable. If you provide a fieldCreatedAt then this is determined by whether the object has a value at that field. Otherwise it's determined by whether the new value was previously undefined.
The returned value will be merged into the local value, applying any server defaults or created/updated times from the server value. See onSaved for more details.
import { observable } from "@legendapp/state";
import { syncedCrud } from "@legendapp/state/sync-plugins/crud";
const profile$ = observable(syncedCrud({
// ...
create: async (value, options) => {
const { data, error } = await serverCreateProfile(value);
if (error) {
// Handle error, throw an Error to trigger a retry
} else if (data) {
return data;
}
},
fieldCreatedAt: 'created_at'
}))update
If an element in the observable is updated it will call the update function with the changed value. If you've enabled the updatePartial option then the value will include only the changed fields and the id. Otherwise it will be the full changed object.
The returned value will be merged into the local value, applying any server defaults or created/updated times from the server value. See onSaved for more details.
import { observable } from "@legendapp/state";
import { syncedCrud } from "@legendapp/state/sync-plugins/crud";
const profile$ = observable(syncedCrud({
// ...
update: async (value, options) => {
const { data, error } = await serverUpdateProfile(value);
if (error) {
// Handle error, throw an Error to trigger a retry
} else if (data) {
return data;
}
},
fieldUpdatedAt: 'updated_at',
updatePartial: true // Update with only changed fields
}))delete
When an element is deleted from the observable, it will call the delete function with the previous row value, including its id.
import { observable } from "@legendapp/state";
import { syncedCrud } from "@legendapp/state/sync-plugins/crud";
const profile$ = observable(syncedCrud({
// ...
delete: async ({ id }, options) => {
const { data, error } = await serverDeleteProfile(id);
if (error) {
// Handle error, throw an Error to trigger a retry
} else if (data) {
return data;
}
}
}))Alternatively if you do soft deletes, you can provide a fieldDeleted option instead of delete, and then it will call the update function with that field set to true.
import { observable } from "@legendapp/state";
import { syncedCrud } from "@legendapp/state/sync-plugins/crud";
const profile$ = observable(syncedCrud({
// ...
update: () => {/* ... */},
fieldDeleted: 'deleted'
}))onSaved
Rows returned by create and update are automatically merged into the local observable, excluding fields changed locally while the save was in flight.
If you want more control over what fields are updated in your object you can use onSaved. Return an object with the fields you want merged into the observable. Returning nothing preserves the automatic merge, so you can also use this for side effects.
const profile$ = observable(syncedCrud({
// ...
create: () => {/* ... */},
update: () => {/* ... */},
onSaved: ({ saved, input, currentValue, isCreate }) => {
return {
serverValue: saved.serverValue
}
}
}))subscribe
If your backend has a realtime feature, or if you want to poll periodically for changes, use subscribe to set that up. It starts before the first get and may restart when the observable is observed again.
This can be used in two ways depending on how your backend works, updating with incoming data or simply triggering a refresh.
When the observable is no longer being observed it will call the returned unsubscribe function.
const profile$ = observable(syncedCrud({
// ...
list: () => {/* ... */},
subscribe: ({ refresh, update }) => {
const unsubscribe = pusher.subscribe({ /*...*/ }, (data) => {
// Either update with the received data
update({ value: data })
// Or trigger a refresh of the get function
refresh()
})
// return unsubscribe function
return unsubscribe
}
}))Sync only diffs
An optional but very useful feature is the changesSince: 'last-sync' option. This can massively reduce bandwidth 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, your
list({ lastSync })function uses that timestamp to query only recent changes - The new changes will be merged into the observable
This has a few requirements to work correctly:
- Set the
fieldUpdatedAtwith a field that is automatically updated by your backend on save. It should not be set on the frontend because inaccurate user clocks might cause data to be missed. - Use soft deletes instead of deleting rows or include deleted rows in your list function. If the list function does not include rows deleted since the last update, the frontend will not know to delete them. You can enable this by adding a
deletedfield in your backend and setting thefieldDeletedoption.
All options
get: Get a single value from the backendlist: List an array of values from the backendcreate: Create a single value on the backendupdate: Update a single value on the backenddelete: Delete a single value on the backendonSaved: Update local value with remote datafieldId: Row identifier field, defaults toidfieldCreatedAt: Set the field your backend uses for created timesfieldUpdatedAt: Set the field your backend uses for updated timesfieldDeleted: Set the field your backend uses for soft deletesfieldDeletedList: Recognize a separate deletion marker in list results without enabling soft-delete writesupdatePartial: Send only changed fields into update functionchangesSince: 'all' | 'last-sync'. Defaults to 'all'. 'last-sync' syncs only diffsgenerateId: Provide a function for creating row ids.subscribe: Set up a realtime connection or pollingretry: Options for retrying in case of error. Applies to both get and set.persist: Options for persisting locally. See Persist and sync.debounceSet: Debounce saved changes to reduce the number of updatesmode: 'set' | 'assign' | 'merge' | 'append' | 'prepend'. How to apply incoming changes.transform: Transform data as it loads in from the remote source or out as it saves to the remote source. You could use this to encrypt the data or transform it into some other format.waitFor: A Promise or Observable to wait for before getting from remotewaitForSet: A Promise or Observable to wait for before setting to remote