Persistence
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.
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.
persistObservable
returns the observable with an additional state
child that can be used to check it’s loading state.
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
First choose and configure the storage plugin for your platform.
Local Storage (React)
import { configureObservablePersistence } from '@legendapp/state/persist'
import { ObservablePersistLocalStorage } from '@legendapp/state/persist-plugins/local-storage'
// Global configuration
configureObservablePersistence({
pluginLocal: ObservablePersistLocalStorage
})
IndexedDB (React)
import { configureObservablePersistence } from '@legendapp/state/persist'
import { ObservablePersistIndexedDB } from '@legendapp/state/persist-plugins/indexeddb'
// Global configuration
configureObservablePersistence({
pluginLocal: ObservablePersistIndexedDB
})
MMKV (React Native)
import { configureObservablePersistence } from '@legendapp/state/persist'
import { ObservablePersistMMKV } from '@legendapp/state/persist-plugins/mmkv'
// Global configuration
configureObservablePersistence({
// Use react-native-mmkv in React Native
pluginLocal: ObservablePersistMMKV
})
AsyncStorage (React Native)
import { configureObservablePersistence } from '@legendapp/state/persist'
import { ObservablePersistAsyncStorage } from '@legendapp/state/persist-plugins/async-storage'
// Global configuration
configureObservablePersistence({
// 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 store$ = observable({ store: { bigObject: { ... } } })
// Persist this observable
persistObservable(store$, {
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 { ObservablePersistIndexedDB } from "@legendapp/state/persist-plugins/indexeddb"
configureObservablePersistence({
pluginLocal: ObservablePersistIndexedDB,
local: {
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: "documents", // 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
, depending on which one you choose to use.
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 the observable itself along with a state
child. Note that the state
is not actually in the raw data and is only accessible through the observable.
const { state } = persistObservable(settings, {
pluginRemote: ...,
remote: {
transform: {
in: (value) => decrypt(value),
out: (value) => encrypt(value),
},
onSaveError: (err) => console.log(err),
}
})
when(state.isLoaded, () => console.log('Loaded from remote'))
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"
const obs$ = 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: '' })
persistObservable(state$, {
pluginRemote: persistPluginFetch({
get: 'https://url.to.get',
set: 'https://url.to.set'
})
})
TanStack Query Plugin
persistPluginQuery
includes all of the normal Query parameters, but instead of updates triggering a re-render, it updates an observable. The queryKey can be a function that returns a key array dependent on some observabes. If those observables change it will update the queryKey and re-run with the new key. That makes it super easy to do pagination, for example.
Try it in a sandbox.
import { observable } from "@legendapp/state"
import { persistObservable } from "@legendapp/state/persist"
import { persistPluginQuery } from "@legendapp/state/persist-plugins/query"
import { QueryClient } from "@tanstack/react-query"
const queryClient = new QueryClient();
const page$ = observable(1)
const obs$ = persistObservable(
{ initialValue: "hello" },
{
pluginRemote: persistPluginQuery({
queryClient,
query: {
// queryKey is a computed function that updates the query when page$ changes
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.