Persistence Strategies
Persist the entire state object or isolate durable slices with explicit selectors and appliers.
Persistence is opt-in and belongs to each store, not to the application as a whole. Omit both adapter and persist for in-memory state. Add an adapter only when that store must cross a storage boundary; every persisted store requires a stable storeId.
| Required lifetime | Store configuration |
|---|---|
| Until reload | No adapter; state stays in memory |
| Across reloads in the current tab | createSessionStorageAdapter() |
| Across later browser sessions | createLocalStorageAdapter() or createIndexDbAdapter() |
| Application-defined | Custom adapter |
Every persisted store must provide a stable storeId. A Devtools display name
never supplies persistence identity.
Built-in Web Storage adapters subscribe to browser events. Configure a
shape-specific sync.validator for those stores even when no browser
synchronization source is enabled; the validator protects memory from
adapter-originated external values.
These choices can coexist. For example, ephemeral UI state can use an in-memory nusm store while session evidence uses session storage and preferences use local storage.
Within one store, the slices strategy persists selected fields and leaves all other fields in memory. Use separate stores when values have separate ownership or lifecycles; use slices when they form one logical store.
Entire-store persistence
Use entire when the whole state object belongs to one persistence lifecycle.
const store = createNusmStore(
{
filters: { status: "all" },
layout: "comfortable",
},
{
adapter: createLocalStorageAdapter(),
persist: { strategy: "entire" },
storeId: "workspace-preferences",
sync: {
validator: (candidate) =>
typeof candidate === "object" &&
candidate !== null &&
typeof (candidate as { layout?: unknown }).layout === "string" &&
typeof (candidate as { filters?: unknown }).filters === "object" &&
(candidate as { filters?: { status?: unknown } }).filters !== null &&
["all", "open", "closed"].includes(
String(
(candidate as { filters: { status?: unknown } }).filters.status,
),
),
},
},
);The default physical key is:
nusm:workspace-preferences:entireOn hydration, persisted data deep-merges over initial state. Arrays are replaced rather than concatenated. Supply hydrate.merge when your schema needs another policy.
Slice persistence
Use slices when only part of the store should survive, when fields need separate keys, or when unrelated updates should not schedule a write.
import type { PersistSlice } from "nusm";
type WorkspaceState = {
draft: string;
filters: { status: "all" | "open" | "closed" };
online: boolean;
};
const slices: Array<PersistSlice<WorkspaceState>> = [
{
key: "filters",
select: (state) => state.filters,
apply: (state, value) => ({
...state,
filters: value as WorkspaceState["filters"],
}),
},
];
const store = createNusmStore<WorkspaceState>(
{ draft: "", filters: { status: "all" }, online: true },
{
adapter: createLocalStorageAdapter(),
persist: { slices, strategy: "slices" },
storeId: "workspace",
sync: {
validator: (candidate) =>
typeof candidate === "object" &&
candidate !== null &&
typeof (candidate as { draft?: unknown }).draft === "string" &&
typeof (candidate as { filters?: unknown }).filters === "object" &&
(candidate as { filters?: unknown }).filters !== null &&
["all", "open", "closed"].includes(
String(
(candidate as { filters: { status?: unknown } }).filters.status,
),
) &&
typeof (candidate as { online?: unknown }).online === "boolean",
},
},
);Only a change where Object.is(previousSelected, nextSelected) is false schedules that slice. The default key is nusm:workspace:slice:filters.
Slice invariants
- Logical
keyvalues must be unique. - Adapter-resolved physical keys must be non-empty and unique.
applymust change only the slice represented by its matchingselect.- Multiple missing slices are validated together before baselines are written.
- A clear event resets configured slices to their initial selected values while leaving non-persisted fields alone.
These constraints prevent one slice from silently changing another slice's durable baseline.
Write scheduling
Each adapter owns its pacing policy:
const adapter = createLocalStorageAdapter({
pacer: {
leading: false,
trailing: true,
wait: 100,
},
});Set pacer: false to start a write immediately for every scheduled persistence operation:
const adapter = createSessionStorageAdapter({ pacer: false });The built-in defaults are 50 ms trailing for local and session storage, and 100 ms trailing for IndexedDB.
Key layout
Built-in adapters accept prefix where applicable. A custom adapter can take complete control with resolveKey:
resolveKey: ({ storeId, kind, sliceKey }) =>
kind === "entire" ? `app:${storeId}` : `app:${storeId}:${sliceKey}`;nusm resolves each configured unit once and reuses that key for hydration, writes, external events, and Devtools commands.
Authenticated browser synchronization
Synchronization is an explicit, authenticated source configuration independent of adapter choice. After hydration, a peer can request a signed snapshot; remote state passes the receiving store's validator before memory or persistence changes. Slice stores write only selected values that changed, and remote applications do not rebroadcast themselves.
See Authenticated synchronization for runtime
key provisioning, namespace and storeId requirements, validator overrides,
optimistic readiness, bounded protocol behavior, and disposal.