Persistence
Note: This documentation is for Legend-State version 2.0 beta. The interfaces for persistence are changing between versions 1 and 2, so if you are starting with persistence we recommend you upgrade to the beta version of 2.0.
A primary goal of Legend-State is to make automatic persistence easy and very robust, as it's meant to be used to power all storage and sync of complex apps - it's the backbone of both Legend and Bravely. It's designed to support offline-first apps: any changes made while offline can be persisted between sessions to be retried in a future session when connected. To do this, the persistence system simply subscribes to changes on an observable, then on change goes through a multi-step flow to ensure that changes are persisted.
- Save the pending changes to the metadata table in local persistence
- Save the changes to local persistence
- Save the changes to remote persistence
- On remote save, set any needed changes (like dateModified) back into the observable
- Clear the pending changes in the metadata table in local persistence
It also includes options to transform data in and/or out, and has event handlers for every step in the lifecycle.
npm i @legendapp/state@beta
persistObservable
persistObservable
can be used to automatically persist an observable, both locally and remotely. It will be saved whenever you change anything anywhere within the observable, and the observable will be filled with the local state right after calling persistObservable
.
The second parameter to persistObservable
provides some options:
local
: A unique name for this observable in storage or options to configure itpluginLocal
: The local persistence plugin to use. This defaults to use the globally configured pluginLocal.remote
: Options to configure remote persistencepluginRemote
: The persistence plugin to use. This defaults to use the globally configured pluginRemote.
First you most likely want to set a global configuration for which plugins to use, though it can also be configured per observable.
Local Persistence Plugins
Web:
import { configureObservablePersistence } from '@legendapp/state/persist'
import { ObservablePersistLocalStorage } from '@legendapp/state/persist-plugins/local-storage'
// OR
import { ObservablePersistLocalIndexedDB } from '@legendapp/state/persist-plugins/indexeddb'
// Global configuration
configureObservablePersistence({
// Use Local Storage
pluginLocal: ObservablePersistLocalStorage
// Or IndexedDB
pluginLocal: ObservablePersistLocalIndexedDB
})
React Native:
import { configureObservablePersistence } from '@legendapp/state/persist'
import { ObservablePersistMMKV } from '@legendapp/state/persist-plugins/mmkv'
// OR
import { ObservablePersistAsyncStorage } from '@legendapp/state/persist-plugins/async-storage'
// Global configuration
configureObservablePersistence({
// Use react-native-mmkv in React Native
pluginLocal: ObservablePersistMMKV
// Use AsyncStorage in React Native
pluginLocal: ObservablePersistAsyncStorage,
localOptions: {
asyncStorage: {
// The AsyncStorage plugin needs to be given the implementation of AsyncStorage
AsyncStorage
}
}
})
Then call persistObservable
for each observable you want to persist.
import { persistObservable } from '@legendapp/state/persist'
const state$ = observable({ store: { bigObject: { ... } } })
// Persist this observable
persistObservable(state$, {
local: 'store' // Unique name
})
IndexedDB
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.
import { persistObservable } from '@legendapp/state/persist'
import { ObservablePersistLocalIndexedDB } from '@legendapp/state/persist-plugins/indexeddb'
configureObservablePersistence({
persistLocal: ObservablePersistIndexedDB,
persistLocalOptions: {
indexedDB: {
databaseName: 'Legend',
version: 1,
tableNames: [
'documents',
'store',
],
},
},
})
// Mode 1: Persist a dictionary
const state$ = observable({
obj1: { id: 'obj1', text: '...' },
obj2: { id: 'obj2', text: '...' },
})
persistObservable(state$, {
local: 'store' // IndexedDB table name
})
// Mode 2: Persist an object with itemId
const settings = observable({ theme: 'light' })
persistObservable(settings, {
local: {
name: 'store', // IndexedDB table name
indexedDB: {
itemID: 'settings',
},
}
})
Because IndexedDB is an asynchronous API, you'll need to wait for it to load before you start reading from it. persistObservable
returns an Observable with load statuses that you can wait for.
const status = persistObservable(state$, {
local: 'store' // IndexedDB table name
})
await when(status.isLoadedLocal)
...
React Native
If you are on React Native you will need to install react-native-mmkv
or @react-native-async-storage/async-storage
:
npm i react-native-mmkv
or
npm i @react-native-async-storage/async-storage
Remote Persistence
Legend-State includes a few remote persistence plugins, and it's very easy to make a custom remote persistence plugin. When using the remote plugins there are some options that can be very useful.
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.offlineBehavior
:false
|'retry'
determines whether to persist changes to retry them on the next loadonBeforeSet
: Called before saving to the remoteonGetError
: Called if load from remote failsonSet
: Called after successful save to the remoteonSetError
: Called if save to remote failswaitForGet
: A Promise or Observable to wait for before getting from remotewaitForSet
: A Promise or Observable to wait for before setting to remotemanual
: If true it will not get immediately, but will wait for you to call sync() on the returned syncState
Returns an array of:
observable
: The persisted observable. If given an initialValue object,persistObservable
will create an observable from it. Or if given an observable it will persist it.syncState
: An observable with many statuses of local and remote persistence
const [obs$, syncState$] = persistObservable(settings, {
pluginRemote: ...,
remote: {
transform: {
in: (value) => decrypt(value),
out: (value) => encrypt(value),
},
onSaveError: (err) => console.log(err),
}
})
This documentation is still under construction. The TypeScript types should hopefully be pretty clear for now, and we will update these docs soon!
Generic remote persistence
All you need to make a remote persistence plugin is a get
and a set
function. You could use this to load/save each observable in a different way or build your own custom plugins.
get()
: Return a value or a Promise of a valueset({ value, changes })
: Save the value somewhere, return a Promise.
import { persistObservable } from "@legendapp/state/persist"
import { persistPluginQuery } from "@legendapp/state/persist-plugins/query"
const [obs$, syncState$] = persistObservable({ initialValue: 'hello' }, {
pluginRemote: {
get: ({ onChange }) => {
const getFn = () => fetch('https://url.to.get')
.then((res) => res.json())
// Set a timer to poll every 10 seconds
setInterval(async () => {
const value = getFn()
onChange({ value })
}, 10000)
// Return the initial value
return getFn()
},
set: async ({ value, changes }) => {
await fetch('https://url.to.set', { method: 'POST', body: JSON.stringify(value) })
}
}
})
Fetch plugin
Try it in a sandbox.
import { persistObservable } from "@legendapp/state/persist"
import { persistPluginQuery } from "@legendapp/state/persist-plugins/query"
const state$ = observable({ name: '' })
const [, syncState$] = persistObservable(state$}, {
pluginRemote: persistPluginFetch({
get: 'https://url.to.get',
set: 'https://url.to.set'
})
})
TanStack Query Plugin
Try it in a sandbox.
import { persistObservable } from "@legendapp/state/persist"
import { persistPluginQuery } from "@legendapp/state/persist-plugins/query"
const page$ = observable(1)
const [obs$, syncState$] = persistObservable({ initialValue: 'hello' }, {
pluginRemote: persistPluginQuery({
queryClient,
query: {
queryKey: ["key", page$.get()],
queryFn: () => {
return fetch(
"https://url.to.get?page=" + page$.get()
).then((res) => res.json())
}
}
})
})
Firebase Realtime Database Plugin
The Firebase Realtime Database plugin has some extra options. The only required option is refPath
which is the ref path in the database. It also has some other useful options:
refPath
: Given the user's UID (if available) return a string of the Firebase pathrequireAuth
: Wait until we have an active Firebase Auth user before loadingquery
: Given the reference to the refPath, you can adjust it furthermode
: "once" | "realtime" - defaults to "realtime". "once" gets the value once and does not setup any listeners.
import { ObservablePersistLocalStorage } from '@legendapp/state/persist-plugins/local-storage'
import { ObservablePersistFirebase } from '@legendapp/state/persist-plugins/firebase'
import { persistObservable } from '@legendapp/state/persist'
persistObservable(state.user, {
pluginLocal: ObservablePersistLocalStorage,
local: {
name: 'user',
},
pluginRemote: ObservablePersistFirebase,
remote: {
transform: {
in: (value) => decrypt(value),
out: (value) => encrypt(value),
},
onSaveError: (err) => console.error(err),
firebase: {
refPath: (uid) => `/users/${uid}/`,
requireAuth: true,
},
},
})
Roll your own plugin
Local persistence plugins are very simple - all you need is get
, set
, and delete
. See ObservablePersistLocalStorage for an example of what it looks like. Then you can just pass your provider into configureObservable
.
Remote persistence plugins just need get
and set
functions. If you'd like to contribute your own please post a GitHub issue and we'll work with you on it.
Contribute
The plugin system is designed to be used for all sorts of providers, so please request additional providers or ideally even submit a PR with an additional plugin provider. If you do build your own plugin, please let us know as we'd love to have a library of many officially supported plugins.