Any store. Any storage.One middleware.
Stop rewriting persist glue when you swap zustand for TanStack Store — or localStorage for IndexedDB.
Zero-deppersistSourcecore; codecs, backends, transport, and framework gates are opt-in subpaths.
import { Store } from "@tanstack/store";
import { createJSONStorage } from "@stainless-code/persist";
import { persistStore } from "@stainless-code/persist/sources/tanstack-store";
const store = new Store({ theme: "light" });
persistStore(store, {
name: "app:prefs:v1",
storage: createJSONStorage(() => localStorage),
});import { create } from "zustand";
import { createJSONStorage } from "@stainless-code/persist";
import { persistStore } from "@stainless-code/persist/sources/zustand";
const usePrefs = create(() => ({ theme: "light" as const }));
persistStore(usePrefs, {
name: "app:prefs:v1",
storage: createJSONStorage(() => localStorage),
});import { atom, createStore } from "jotai";
import { createJSONStorage } from "@stainless-code/persist";
import { persistAtom } from "@stainless-code/persist/sources/jotai";
const store = createStore();
const themeAtom = atom<"light" | "dark">("light");
persistAtom(store, themeAtom, {
name: "app:theme:v1",
storage: createJSONStorage(() => localStorage),
});import { proxy } from "valtio";
import { createJSONStorage } from "@stainless-code/persist";
import { persistProxy } from "@stainless-code/persist/sources/valtio";
const prefs = proxy({ theme: "light" as const });
persistProxy(prefs, {
name: "app:prefs:v1",
storage: createJSONStorage(() => localStorage),
});import { observable } from "mobx";
import { createJSONStorage } from "@stainless-code/persist";
import { persistObservable } from "@stainless-code/persist/sources/mobx";
const prefs = observable.object({ theme: "light" as const });
persistObservable(prefs, {
name: "app:prefs:v1",
storage: createJSONStorage(() => localStorage),
});import { defineStore } from "pinia";
import { createJSONStorage } from "@stainless-code/persist";
import { persistStore } from "@stainless-code/persist/sources/pinia";
const usePrefs = defineStore("prefs", {
state: () => ({ theme: "light" as const }),
});
persistStore(usePrefs(), {
name: "app:prefs:v1",
storage: createJSONStorage(() => localStorage),
});import { createStore } from "redux";
import { createJSONStorage } from "@stainless-code/persist";
import {
persistStore,
persistableReducer,
} from "@stainless-code/persist/sources/redux";
const store = createStore(persistableReducer(rootReducer));
persistStore(store, {
name: "app:root:v1",
storage: createJSONStorage(() => localStorage),
});import { createJSONStorage, persistSource } from "@stainless-code/persist";
persistSource(
{
getState: () => myStore.getState(),
setState: (updater) => myStore.setState(updater),
subscribe: (listener) => ({
unsubscribe: myStore.subscribe(() => listener()),
}),
},
{
name: "app:prefs:v1",
storage: createJSONStorage(() => localStorage),
},
);bun add @stainless-code/persist @tanstack/storebun add @stainless-code/persist zustandbun add @stainless-code/persist jotaibun add @stainless-code/persist valtiobun add @stainless-code/persist mobxbun add @stainless-code/persist piniabun add @stainless-code/persist reduxbun add @stainless-code/persistWhen to use it
When to reach for Persist.
Client store state is durable and cross-cutting, but most persist glue is store-locked. Persist binds to a structural source and a hydration lifecycle you can gate from any framework.
| Use case | What it involves | Fit |
|---|---|---|
| Persist prefs / draft state across reloads without a hydrate flash | toHydrationSignal + framework useHydrated | Ideal |
| Same persist middleware across zustand, TanStack Store, jotai, … | PersistableSource + ./sources/* | Ideal |
| Swap localStorage ↔ IndexedDB ↔ encrypted wrapper without rewrite | createStorage(backend, codec) seams | Ideal |
| Versioned schema evolution for long-lived keys | version + migrate / createMigrationChain | Ideal |
| Keep tabs in sync (or BroadcastChannel over IDB) | crossTab + ./transport/crosstab | Ideal |
| Survive quota errors without clobbering newer writes | retryWrite (shrink-or-give-up + generation guard) | Ideal |
| Schema-gate prefs or round-trip Date/Map/Set | ./codecs/standard-schema · ./codecs/seroval | Good |
| Clear every registered key on logout | createPersistRegistry + clearAll | Nice-to-have |
| Gate UI the same way in React / Preact / Solid / Angular / Vue / Lit / Alpine / Svelte | ./frameworks/* hydration adapters | Ideal |
When to skip it.
Persist pays for itself when you need a hydration signal, swappable seams, or store-agnostic middleware. A one-off sync write is cheaper as plain storage.
| Use case | Fit |
|---|---|
A single sync localStorage.setItem with no hydrate gate, migrate, or multi-tab needs | Skip → plain storage helpers |
| Persisting a query/cache layer (TanStack Query, SWR, …) — different lifecycle | Skip → that library's persist client |
| Server-authoritative state with no client durable copy | Skip → your API + cache policy |
Three seams. Swap any cell.
Backend × codec × source compose throughcreateStorageandpersistSource— framework adapters only mount the hydration signal.
Backends
- localStorage / session
- IndexedDB
- AsyncStorage
- MMKV
- Secure Store
- Node fs
- Encrypted
- Compressed
Codecs
- JSON
- seroval
- Standard Schema
- identity
Sources
- TanStack Store
- Zustand
- Jotai
- Valtio
- MobX
- Pinia
- Redux
- Any source
Frameworks
- React
- Preact
- Solid
- Angular
- Vue
- Lit
- Alpine
- Svelte (runes)
- Svelte (store)
| Concern | Where it lives |
|---|---|
| Wire format / validation | ./codecs/* |
| Durable key/value IO | ./backends/* · core factories |
Store shape → PersistableSource | ./sources/* |
| Hydration gate in UI | ./frameworks/* |
| Multi-tab over async backends | ./transport/crosstab |
Batteries included.
Handle the persist cases that stop being local: async hydrate flash, schema evolution, quota retries, multi-tab sync, and rich serialization. Import only the subpaths you need.
Compose backend × codec × source
createStorage + persistSource — swap IndexedDB or seroval without touching the store.Gate UI until hydration settles
toHydrationSignal + useHydrated — no flash on async backends.Migrate versioned payloads
version + migrate / createMigrationChain for long-lived keys.Sync tabs without custom listeners
crossTab; BroadcastChannel over IDB via ./transport/crosstab.Survive quota without clobbering
retryWrite shrinks-or-gives-up with a write-generation guard.Encrypt or compress the wire
./backends/encrypted and ./backends/compressed over any storage.Schema-gate or keep rich types
./codecs/standard-schema blocks invalid prefs; ./codecs/seroval keeps Date/Map/Set.Wrap any store shape
PersistableSource.No framework in the core
HydrationSignal into React, Preact, Solid, Angular, Vue, Lit, Alpine, Svelte.Start persisting without the flash.
Install the core, then add peers only for the subpaths you import.
bun add @stainless-code/persist @tanstack/storebun add @stainless-code/persist zustandbun add @stainless-code/persist jotaibun add @stainless-code/persist valtiobun add @stainless-code/persist mobxbun add @stainless-code/persist piniabun add @stainless-code/persist reduxbun add @stainless-code/persist