Skip to content

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.

  1. Save the pending changes to the metadata table in local persistence
  2. Save the changes to local persistence
  3. Save the changes to remote persistence
  4. On remote save, set any needed changes (like dateModified) back into the observable
  5. 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 it
  • pluginLocal: The local persistence plugin to use. This defaults to use the globally configured pluginLocal.
  • remote: Options to configure remote persistence
  • pluginRemote: 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:

  1. Persisting a dictionary where each value has an id field, and each value will create a row in the table
  2. 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: "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, 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 load
  • onBeforeSet: Called before saving to the remote
  • onGetError: Called if load from remote fails
  • onSet: Called after successful save to the remote
  • onSetError: Called if save to remote fails
  • waitForGet: A Promise or Observable to wait for before getting from remote
  • waitForSet: A Promise or Observable to wait for before setting to remote
  • manual: 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 value
  • set({ 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$ = 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 path
  • requireAuth: Wait until we have an active Firebase Auth user before loading
  • query: Given the reference to the refPath, you can adjust it further
  • mode: “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.