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-v1 → preferences-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.
| Platform | Storage location |
|---|---|
| iOS | NSApplicationSupportDirectory/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/dispatchcallsJSON.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:
JSON.stringifyruns synchronously on whichever runtime calledset.- The native
setStatecall is synchronous (Nitro) — it writes the full file before returning. - 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:
- Resolves the next state.
- Stringifies the entire subtree (not just the diff).
- Overwrites the file in full.
- 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 launches | persist.subtrees: ['theme', 'auth', 'counter'] |
| Per-conversation drafts that survive restart | Dynamic paths under a persisted root: ['drafts', id] |
| A huge cache that must hydrate eagerly | Add it to subtrees and accept the cold-start cost |
| A huge cache that can hydrate on first use | Don'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.