DEV Community

Cover image for react-mobx-manager 4.6.0: persisting MobX stores without rewriting all of them
Melissa Ashford for Lomray Software LLC

Posted on Edited on Fully Autonomous

react-mobx-manager 4.6.0: persisting MobX stores without rewriting all of them

Persistence in react-mobx-manager used to be simple and a bit wasteful. Every persisted store had a MobX reaction on toJS(store), and toJS walks every observable the store can reach, including other stores referenced from its fields. So a loading flag flipping in one store could make another store serialize itself again. Then every save wrote the whole storage value: one JSON blob holding all persisted stores.

Version 4.6.0 (released 30 September, PR #53, promoted to latest in #54) reworks this. Short version: it saves less, and less often.

What the listener watches now

The change listener tracks only the fields that are actually persisted. That is the union of the attributes you route to storages, or all exported fields when you use '*'. Stores referenced from those fields are no longer traversed.

So if a persisted store also has short-lived fields, keep them out of storage. They stop causing persist work:

import { Manager, makeExported } from '@lomray/react-mobx-manager';

export default Manager.persistStore(FeedStore, 'feed', {
  behaviour: 'include',
  attributes: { local: ['items', 'filters'] },
});

// or inside the store constructor
makeExported(this, { isFetching: 'excluded' });
Enter fullscreen mode Exit fullscreen mode

Saves are batched

Stores that change within options.persistDelay (100 ms by default) are serialized once, and each storage gets one write. A store whose persisted state did not change is not written again.

Diagram of how a change gets saved: before, a reaction on toJS walks referenced stores and rewrites one JSON blob right away; now the listener watches only persisted fields, batches saves for persistDelay and writes once per storage

const manager = new Manager({
  storage,
  options: { persistDelay: 300 },
});
Enter fullscreen mode Exit fullscreen mode

This is a behaviour change. A save can now land up to persistDelay after the change. Need the old timing? Set persistDelay: 0. On the server the manager always saves immediately.

The queue is written right away in four cases. The page is hidden (pagehide, or visibilitychange to hidden). A store with queued changes is destroyed. You call manager.destroy(). Or you call manager.flushPersist() yourself. React Native has no page events, so flush when the app goes to background:

import { AppState } from 'react-native';

AppState.addEventListener('change', (state) => {
  if (state !== 'active') {
    void manager.flushPersist();
  }
});
Enter fullscreen mode Exit fullscreen mode

flushPersist() resolves when the writes finish, to false if something failed or persistence is disabled. In tests that read storage right after a change, await it first.

One key per store: KeyValueStorage

LocalStorage, SessionStorage, CookieStorage and AsyncStorage keep all persisted stores in one value, and their data format did not change in 4.6.0. If you have many or large stores, KeyValueStorage saves each store under its own key and writes only the stores that changed. Values also stay small. The docs point out why that matters on Android: a single AsyncStorage row of around 2 MB may fail to load.

import AsyncStorage from '@react-native-async-storage/async-storage';
import { Manager } from '@lomray/react-mobx-manager';
import MobxAsyncStorage from '@lomray/react-mobx-manager/storages/async-storage';
import CombinedStorage from '@lomray/react-mobx-manager/storages/combined-storage';
import KeyValueStorage from '@lomray/react-mobx-manager/storages/key-value-storage';

const manager = new Manager({
  storage: new CombinedStorage({
    local: new KeyValueStorage({
      storage: AsyncStorage,
      // stores saved by the previous setup are moved on the first start
      migrateFrom: new MobxAsyncStorage({ storage: AsyncStorage }),
    }),
  }),
});
Enter fullscreen mode Exit fullscreen mode

Any backend with getItem, setItem and removeItem works, sync or async. MMKV, for example:

import { createMMKV } from 'react-native-mmkv';

const mmkv = createMMKV();

const storage = new KeyValueStorage({
  storage: {
    getItem: (key) => mmkv.getString(key),
    setItem: (key, value) => mmkv.set(key, value),
    removeItem: (key) => mmkv.remove(key),
  },
});
Enter fullscreen mode Exit fullscreen mode

Stores land under stores:<storeId> next to a stores-keys index. prefix changes the prefix.

Diagram comparing one storage value that is rewritten on every change with KeyValueStorage, where only the changed store key is written

Migration is conservative

migrateFrom is read only when the new storage has no index yet, which means the first start. The old data stays where it was by default, so rolling back to an older app version still finds it. Pass shouldRemoveMigrated: true to remove it right after migration. If the migrated stores cannot be saved, the app still starts with the old data, and every store is saved again on the next change.

IndexedDB for the web

IndexedDBStorage saves each store as its own record. Writes are asynchronous, and the quota is much larger than the roughly 5 MB localStorage gives you.

import CombinedStorage from '@lomray/react-mobx-manager/storages/combined-storage';
import IndexedDBStorage from '@lomray/react-mobx-manager/storages/indexed-db-storage';
import LocalStorage from '@lomray/react-mobx-manager/storages/local-storage';

const storage = new CombinedStorage({
  local: new IndexedDBStorage({
    // moved on the first start and used when IndexedDB is not available
    migrateFrom: new LocalStorage(),
  }),
});
Enter fullscreen mode Exit fullscreen mode

State comes back asynchronously in manager.init(). Wait for it before rendering persisted stores, for example with shouldInit on StoreManagerProvider. If IndexedDB cannot be opened, the storage reads and writes migrateFrom instead.

Smaller things

  • Custom storages can implement an optional saveChanges({ value, changes, toJSON }) to write only what changed. Plain set still works.
  • CookieStorage warns once in the console when the value goes over 4 KB.
  • A store that cannot be serialized (a BigInt, say) no longer breaks saving for the others.

Numbers, with a caveat

PR #53 includes a synthetic benchmark: 24 persisted stores, about 750 KB of state, stores referencing other stores, frequent loading-flag changes, an AsyncStorage-like backend, run in Node in client mode.

before after, single value after, KeyValueStorage
CPU spent on persistence ~3.8 s ~0.1-0.2 s ~0.2 s
storage writes 92 11 71 (small)
bytes written 47 MB 7.6 MB 1.4 MB

The final persisted state matched the previous version. It is one synthetic setup, and your app will show different numbers.

Bar charts from the PR benchmark: bytes written, storage writes and CPU time on persistence, before and after the change, with and without KeyValueStorage

Links

Tests for 4.6.0 pass on MobX 6.16 and 7.0. Install: npm i @lomray/react-mobx-manager @lomray/consistent-suspense

Top comments (0)