Persist and Sync
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 - it was built as the backbone of both Legend and Bravely. It’s designed to support local first 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.
- Save the pending changes to local persistence
- Save the changes to local persistence
- Save the changes to remote persistence
- On remote save, set any needed changes (like updatedAt) back into the observable and local persistence
- Clear the pending changes in local persistence
Plugins
The sync features are designed to be used through a plugin for your backend of choice. The plugins are all built on top of synced and are configurable with their own options as well as general sync and persist options.
Database plugins
- Keel: Powerful schema-driven SQL backend we use in Bravely
- Supabase: Popular PostgreSQL backend
- Firebase RTDB: Documentation under construction
These are built on top of the CRUD plugin.
General
- CRUD: Supports any backend with list, get, create, update, delete actions
- Fetch: A wrapper around fetch to reduce boilerplate
- TanStack Query: Query updates observables rather than re-rendering
Example
We’ll start with an example to give you an idea of how Legend-State’s sync works. Because sync and persistence are defined in the observables, your app and UI just needs to work with observables. That immediately updates the UI optimistically, persists changes, and syncs to your database with eventual consistency.
This example binds inputs directly to the remote data and shows you when the changes save. Try going offline and making some changes, then refresh and the changes are still there. Then go back online and watch the saved time update. You may want to open the Network panel of the dev tools to see it in action.
This is a live playground so you can experiment with the different options.
import { observable } from "@legendapp/state" import { observer } from "@legendapp/state/react" import { configureSynced } from "@legendapp/state/sync" import { syncedFetch } from "@legendapp/state/sync-plugins/fetch"; import { ObservablePersistMMKV } from "@legendapp/state/persist-plugins/mmkv" // Setup global sync and persist configuration. These can be overriden // per observable. const mySyncedFetch = configureSynced(syncedFetch, { persist: { plugin: ObservablePersistMMKV, retrySync: true // Persist pending changes and retry }, retry: { infinite: true // Retry changes with exponential backoff } }) // Create a synced observable const profile$ = observable(mySyncedFetch({ get: 'https://reqres.in/api/users/1', set: 'https://reqres.in/api/users/1', setInit: { method: 'PUT' }, // Transform server data to local format transform: { load: (value, method) => method === 'get' ? value.data : value }, // Update observable with updatedAt time from server onSaved: (result) => ({ updatedAt: new Date(result.updatedAt) }), // Persist in local storage persist: { name: 'persistSyncExample', }, // Don't want to overwrite updatedAt mode: 'assign' })) const App = observer(function App() { const updatedAt = profile$.updatedAt.get(); const saved = updatedAt ? new Date(updatedAt).toLocaleString() : 'Never' console.log(profile$.get()) return ( <Box> <Reactive.TextInput $value={profile$.first_name} /> <Reactive.TextInput $value={profile$.last_name} /> <Text> Saved: {saved} </Text> </Box> ) })
Guides
This page will show how you use the core synced. The plugins are built on top of synced
so everything on this page applies to the plugins as well.
Which Platform?
Select React or React Native to customize this guide for your platform.
Persist data locally
Legend-State has a persistence system built in, with plugins for web and React Native. When you initialize the persistence it immediately loads and merges the changes on top of the initial value. Then any changes you make after initialization will be saved to persistence.
You can sync/persist a whole observable or any child, and there are two ways to persist observables: synced
in the observable constructor or syncObservable
later.
In this first example we create an observable with initial data and then use syncObservable
to persist it.
Alternatively we can setup the persistence in the constructor with synced
. This does exactly the same thing as above.
Async persistence
Some persistences like IndexedDB and AsyncStorage are asynchronous, so you’ll need to wait for it to load before you start reading from it. syncState
returns an observable with load statuses that you can wait for.
Sync with a server
Legend-State makes syncing remote data very easy, while being very powerful under the hood. You can setup your sync system directly in the observable itself, so that your application code only interacts with observables, and the observables handle the sync for you.
This is a great way to isolate your syncing code in one place away from your UI, and then your UI code justs gets/sets observables.
Like with persistence you can use either syncObservable
or synced
but we’ll just focus on synced
for this example.
Sync with paging
get()
is an observing context, so if you get an observable’s value it will re-run if it changes. We can use that to created a paging query by setting the query mode to “append” (or “assign” if it’s an object) to append new pages into the observable array.
Local first robust real-time sync
The crud based plugins can be used to enable a robust offline-first sync system by setting a few options. These options will:
- Persist all data locally so the app can work offline
- Continually retry saves so that failure is not an option
- Persist saves locally so that they retry even after refresh
- Sync in realtime
API
configureSynced
Sync plugins have a lot of options so you’ll likely want to set some defaults. You can do that with the configureSynced
function to create a customized version of a plugin with your defaults, to reduce duplication and enforce consistency. You will most likely want to at least set a default persistence plugin.
synced
The easiest way to create a synced observable is to use synced
when creating an observable to bind it to remote data and/or persist it locally. To simply set up persistence, just create get
and set
functions along with a persist
option.
synced
creates a lazy computed function which will not activate until you get()
it. So you can set up your observables’ sync/persist options and they will only activate on demand.
Or a more advanced example with many of the possible options:
syncObservable
If you prefer to set up sync/persistence after the observable is already created, you can use syncObservable
with the same options as synced
. It’s effectively the same as using synced
with an initial value. You can also pass any of the plugins as the second option.
You can also use any sync plugin with syncObservable.
syncState
Each synced observable has a syncState
observable that you can get to check its status or do some actions.
The isLoaded
and error
properties are accessible when using syncState
on any asynchronous Observable, but the others are created when using synced
.
isPersistLoaded: boolean
: Whether it has loaded from the local persistenceisPersistEnabled: boolean
: Enable/disable the local persistenceisLoaded: boolean
: Whether the get function has returnedisSyncEnabled: boolean
: Enable/disable remote synclastSync: number
: Timestamp of the latest syncsyncCount: number
: Number of times it’s syncedclearPersist: () => Promise<void>
: Clear the local persistencesync: () => Promise<void>
: Re-run the get functiongetPendingChanges: () => Record<string, object>
: Get all unsaved changederror: Error
: The latest error
useObservable + synced
Create a synced observable within a React component using useObservable.
Transform data
It’s very common to need to transform data into and out of your persistence or remote server. There is an option on synced
to transform the remote data and an option within the persist
option to transform to/from persistence.
Legend-State includes helpers for easily stringifying data or you can create your own custom transformers.
transformStringifyKeys
: JSON stringify/parse the data at the given keys, for when your backend stores objects as stringstransformStringifyDates
: Transform dates to ISO string, with either the given keys or automatically scanning the object for datescombineTransforms
: Combine multiple transforms together
This can be used in many ways. Some examples:
- Migrate between versions: If the local data has legacy values in it, you can can transform it to the latest format. This can be done by either keeping a version number or just checking for specific fields. This example migrates old persisted data by checking the version and old field name.
- Transform to backend format: If you want to interact with data in a different format than your backend stores it, it can be automatically transformed between the observable and the sync functions. This could be used for stringifying or parsing dates for example. In this example we combine the
transformStringifyDates
andtransformStringifyKeys
helpers with a custom transformer.
- Encrypt: For end-to-end encryption you can encrypt/decrypt in the transformer so that you interact with unencrypted data locally and it’s encrypted before going into your update functions
Persist plugins
First choose and configure the storage plugin for your platform.
Local Storage (React)
IndexedDB (React)
The IndexedDB plugin can be used in two ways:
- Persisting a dictionary where each value has an
id
field, and each value will create a row in the table - Persisting multiple observables to their own rows in the table with the
itemID
option
It requires some extra configuration for the database name, the table names, and the version.
IndexedDB requires changing the version whenever the tables change, so you can start with version 1 and increment the version whenever you add/change tables.
Because IndexedDB is an asynchronous API, observables will not load from persistence immediately, so if you’re persisting a large amount of data you may want to show a loading state while persistence is loading.
MMKV (RN)
First install react-native-mmkv:
Then configure it as the persist plugin.
AsyncStorage (RN)
Older versions of React Native have AsyncStorage built in, but newer versions may need it installed separately. Check the React Native docs for the latest guidance on that.
The AsyncStorage plugin needs an additional bit of global configuration, giving it the instance of AsyncStorage.
Because AsyncStorage is an asynchronous API, observables will not load from persistence immediately, so if you’re persisting a large amount of data you may want to show a loading state while persistence is loading.
Making a sync plugin
Once you’re syncing multiple observables in the same way you’ll likely want to create a plugin that encapsulates the specifics of your backend. The plugin just needs to return a synced. If your backend is CRUD based (it has create, read, update, delete functions) then you may want to build on top of syncedCrud which encapsulates a lot of logic for those specifics for you.
It may be easiest to look at the source of the built-in sync plugins to see what they look like.
This is a simple contrived example to show what that could look like.