React Native Runtimes
Shared state

Persistence

Save shared state across runtime teardown and app restart with persist + subtrees.

Enable persistence when state should survive runtime teardown or app restart. Persistence is native, scoped per store key, and hydrates lazily by default.

export const preferencesStore = createSharedStore({
  name: 'preferences',
  initialState: {
    counter: { count: 0, updatedAt: null },
  },
  persist: {
    key: 'preferences-v1',
    subtrees: ['counter'],
  },
});

export const counter = preferencesStore.path<{
  count: number;
  updatedAt: string | null;
}>('counter');

persist.subtrees lists paths that should be eagerly restored during store hydration. Dynamic paths that are not listed still hydrate lazily when their handle is created or path.hydrate() is called.

Choosing persist.key

The persist key is part of your durable schema. Bump the suffix (preferences-v1preferences-v2) when you change shapes in a way that isn't backwards-compatible.

How it works under the hood

Persistence is deliberately minimal — there is no third-party storage library. Each persisted subtree is written as a raw JSON file on disk.

PlatformStorage location
iOSNSApplicationSupportDirectory/threaded-zustand/<sanitized-key>.json
Android<context.filesDir>/threaded-zustand/<sanitized-key>.json

Reads and writes use std::ifstream / std::ofstream from the shared C++ core. One file per persisted subtree.

Serialization runs in JS

JSON encoding and decoding happen in JavaScript, not in native code:

  • set / update / dispatch calls JSON.stringify() and hands the resulting string to native.
  • Hydration reads the file into a string in native, returns it to JS, and JSON.parse() reconstructs the value.

The native side stores and returns the byte string as-is — there is no intermediate folly::dynamic, nlohmann::json, or binary format.

Which thread does serialization run on?

The calling runtime's JS thread. Not the platform main / UI thread, but also not a dedicated I/O thread:

  1. JSON.stringify runs synchronously on whichever runtime called set.
  2. The native setState call is synchronous (Nitro) — it writes the full file before returning.
  3. The change notification is published only after the write completes.

That means a multi-megabyte payload written from the UI runtime will block UI JS while it stringifies and flushes to disk. The fix is structural, not a setting: persist heavy state from a secondary runtime and let the UI runtime subscribe.

Writes are write-through

There is no batching, debouncing, or dirty-flag. Every mutation against a persisted path:

  1. Resolves the next state.
  2. Stringifies the entire subtree (not just the diff).
  3. Overwrites the file in full.
  4. Bumps the in-memory revision and emits a change event.

100 writes in a row = 100 JSON.stringify calls and 100 file writes.

Don't persist inside hot loops

Avoid calling set on a persisted path from a per-frame callback, gesture handler, or scroll listener. Debounce on your side, or write a summary to the persisted path only when the interaction settles.

Revisions are in-memory only

The native store keeps an integer revision per entry, bumped on every setState. Revisions are not persisted — they reset to 0 on app launch and exist so subscribers can detect missed updates within a session. See Locking and revisions.

Predeclared subtrees

Use subtrees when the store has a small set of known hot paths that should be hydrated immediately:

export const pokemonStore = createSharedStore<PokemonState>({
  name: 'pokemon',
  initialState: {
    catalog: initialCatalog,
    pokemonItems: [],
  },
  subtrees: ['catalog', 'pokemonItems'],
});

export const catalog = pokemonStore.path<PokemonState['catalog']>('catalog');
export const pokemonItems =
  pokemonStore.path<PokemonState['pokemonItems']>('pokemonItems');

Use dynamic paths when ids define ownership:

export const conversationDraft = (conversationId: string) =>
  chatStore.path<string>(['drafts', conversationId]);

The native store tracks revisions per path. Every active runtime receives native change events and updates matching subscribers.

Patterns

You want…Do this
A few preferences saved across launchespersist.subtrees: ['theme', 'auth', 'counter']
Per-conversation drafts that survive restartDynamic paths under a persisted root: ['drafts', id]
A huge cache that must hydrate eagerlyAdd it to subtrees and accept the cold-start cost
A huge cache that can hydrate on first useDon't list it — call path.hydrate() from the screen that needs it

Don't persist what you can recompute

If a value is cheap to recompute on launch (derived from another store, or fetched on screen open), don't persist it. Less state on disk means fewer schema migrations to think about.

On this page