npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@stainless-code/persist

v0.2.0

Published

Hydration-aware persistence for any reactive store — zero-dep persistSource core; codecs, backends, cross-tab transport, source + framework hydration adapters ship as opt-in recipes

Readme

@stainless-code/persist

Hydration-aware persistence middleware for any reactive store — storage × codec seams, TanStack Store adapters, and a React hydration hook. Store-agnostic via a structural PersistableSource; every "can it do X?" is a one-line composition instead of a feature request.

core size

Jump to what you need —

Install

bun add @stainless-code/persist

Each subpath owns its dependency as an optional peer — import only the entries you use, install the matching peer only when you do:

| Subpath | Optional peer | | ------------------------------------------------- | ------------------------------------------- | | @stainless-code/persist | none (zero-dep core) | | @stainless-code/persist/codecs/seroval | seroval | | @stainless-code/persist/codecs/zod | zod | | @stainless-code/persist/backends/idb | idb-keyval | | @stainless-code/persist/backends/async-storage | @react-native-async-storage/async-storage | | @stainless-code/persist/backends/mmkv | react-native-mmkv | | @stainless-code/persist/backends/secure-store | expo-secure-store | | @stainless-code/persist/backends/encrypted | none (web global) | | @stainless-code/persist/backends/compressed | none (web global) | | @stainless-code/persist/backends/node-fs | none (Node built-in) | | @stainless-code/persist/transport/crosstab | none (web global) | | @stainless-code/persist/sources/tanstack-store | @tanstack/store | | @stainless-code/persist/sources/zustand | zustand | | @stainless-code/persist/sources/jotai | jotai | | @stainless-code/persist/sources/valtio | valtio | | @stainless-code/persist/sources/mobx | mobx | | @stainless-code/persist/frameworks/react | react | | @stainless-code/persist/frameworks/solid | solid-js | | @stainless-code/persist/frameworks/vue | vue | | @stainless-code/persist/frameworks/svelte | svelte | | @stainless-code/persist/frameworks/svelte-store | svelte | | @stainless-code/persist/frameworks/angular | @angular/core | | @stainless-code/persist/frameworks/preact | preact |

# only when you use the matching entry
bun add seroval idb-keyval @tanstack/store react

Any package manager works — engines require Node ^20.19.0 || >=22.12.0 (or Bun >=1.0.0).

npm install @stainless-code/persist   # pnpm add / yarn add — same pattern
# optional peers, only for subpaths you import:
npm install seroval idb-keyval @tanstack/store react

Quick start

import { Store } from "@tanstack/store";
import { createSerovalStorage } from "@stainless-code/persist/codecs/seroval";
import { persistStore } from "@stainless-code/persist/sources/tanstack-store";
import { toHydrationSignal } from "@stainless-code/persist";
import { useHydrated } from "@stainless-code/persist/frameworks/react";

const store = new Store({ theme: "light" });
const persist = persistStore(store, {
  name: "app:prefs:v1",
  storage: createSerovalStorage(() => localStorage),
});
export const prefsHydration = toHydrationSignal(persist);

// in a component:
const { hydrated } = useHydrated(prefsHydration);

What does "hydration-aware" mean?

Hydration-aware means the library tracks whether persisted state has finished loading from storage — not React SSR hydration, not rehydrating the DOM from HTML. It answers one question: has the stored snapshot landed in the store yet?

That gap matters on async backends. IndexedDB reads are Promise-backed; they cannot settle before first paint. Between mount and the read completing, the store still holds its constructor default — theme "light" when storage says "dark", filters [] when storage has three. Render persisted-dependent UI in that window and you get a hydrate flash: wrong state, then a snap to the real one. useHydrated gates on that lifecycle; it does not change how you read state.

Sync backends (localStorage, sessionStorage) settle before first render when the store is created at module load — hydrated is true immediately, no gate required. IndexedDB makes the gate mandatory rather than optional.

const { hydrated } = useHydrated(prefsHydration);
if (!hydrated) return <Skeleton />;
// persisted-dependent UI below

IndexedDB + React, end to end

The headline path: async storage, TanStack Store, hydration gate. npm/pnpm/yarn work too.

bun add @stainless-code/persist @tanstack/store react idb-keyval

Store modulecreateIdbStorage runs structured-clone mode: Set / Map / Date round-trip natively, no codec. The persist + hydration signal live at module scope for an app-lifetime singleton store.

// prefs-store.ts
import { Store } from "@tanstack/store";
import { toHydrationSignal } from "@stainless-code/persist";
import { createIdbStorage } from "@stainless-code/persist/backends/idb";
import { persistStore } from "@stainless-code/persist/sources/tanstack-store";

export type Prefs = { theme: "light" | "dark"; recent: string[] };

export const prefsStore = new Store<Prefs>({ theme: "light", recent: [] });

const persist = persistStore(prefsStore, {
  name: "app:prefs:v1",
  storage: createIdbStorage<Prefs>(),
});
export const prefsHydration = toHydrationSignal(persist);

React componentuseHydrated returns only { hydrated }; state reads stay on useSelector. Server snapshot is always true (nothing to gate server-side).

// PrefsPanel.tsx
import { useSelector } from "@tanstack/store";
import { useHydrated } from "@stainless-code/persist/frameworks/react";
import { prefsStore, prefsHydration } from "./prefs-store";

export function PrefsPanel() {
  const { hydrated } = useHydrated(prefsHydration);
  const theme = useSelector(prefsStore, (s) => s.theme);
  if (!hydrated) return <Skeleton />;
  return <ThemeToggle theme={theme} />;
}

Non-singleton stores (a per-route filter store, a per-instance draft editor) must create the persist in useEffect and tear it down on unmount — destroy() detaches the source subscription, removes the cross-tab listener, unregisters from any registry, and flushes any pending throttled write:

useEffect(() => {
  const persist = persistStore(filtersStore, {
    name: `app:filters:${id}`,
    storage: createIdbStorage<Filters>(),
  });
  return () => persist.destroy();
}, [id]);

IndexedDB fires no storage events — crossTab: true alone does nothing on this backend. Cross-tab sync needs a BroadcastChannel bridge wired through crossTabEventTarget (see Recipes).

Relationship to TanStack Persist / zustand persist

Both TanStack Persist and zustand persist wire a single store library to a single storage with a flat options bag. @stainless-code/persist is a middleware model with a first-class hydration lifecycle: persistence is bound to a structural PersistableSource (getState/setState/subscribe) rather than a specific store, so the same middleware persists TanStack Store, zustand, Redux, or a hand-rolled atom. Three seams — backend (StateStorage), codec (StorageCodec), source (PersistableSource) — make every backend × codec cell a one-line composition. The hydration lifecycle (onHydrate / onFinishHydration / hasHydrated, surfaced via HydrationSignal and useHydrated) gates UI flash without coupling to the store's read path, versioned migrate handles schema evolution, crossTab + onCrossTabRemove syncs tabs, and retryWrite shrinks-or-gives-up on quota errors with a write-generation guard so stale retries never clobber newer state.

Comparison with other persist libraries

Every row is a seam or lifecycle concern — not a roadmap item. @stainless-code/persist treats each as composable; incumbents bake most of them into framework-specific middleware.

| Capability | @stainless-code/persist | zustand-persist | redux-persist | @tanstack/query-persist-client | pinia-persist | | -------------------------------------- | :-----------------------: | :-------------: | :-----------: | :------------------------------: | :-----------: | | Store-agnostic (structural source) | ✓ | ✗ | ✗ | ~ | ✗ | | Codec seam (swap serialization) | ✓ | ~ | ✓ | ~ | ~ | | Storage seam (swap backend) | ✓ | ✓ | ✓ | ✓ | ✓ | | Hydration signal (gate UI flash) | ✓ | ~ | ~ | ✓ | ~ | | Cross-tab sync | ✓ | ✗ | ✗ | ✗ | ✗ | | migrate (versioned) | ✓ | ✓ | ✓ | ✗ | ✗ | | retryWrite (quota shrink-or-give-up) | ✓ | ✗ | ✗ | ✓ | ✗ | | throttleMs | ✓ | ✗ | ✓ | ✓ | ✗ | | maxAge / buster expiry | ✓ | ✗ | ✗ | ✓ | ✗ | | Schema validation (codec) | ✓ | ✗ | ✗ | ✗ | ✗ | | Framework hydration adapters | ✓ | ✗ | ✗ | ✓ | ✗ |

Differentiator: @stainless-code/persist is the only library here with built-in cross-tab sync, a schema-validation codec, and a fully store-agnostic source (PersistableSource). The closest peer, @tanstack/query-persist-client, matches on the hydration signal, retryWrite (shrink-or-give-up), throttleMs, maxAge/buster, and framework adapters — but is query-cache-bound (not store-agnostic), exposes no codec seam on its Persister interface, and ships no versioned migrate, cross-tab sync, or schema validation.

Migrating from …

Same mental model everywhere: pick a source (PersistableSource), wire storage, gate UI with HydrationSignal. Option names map 1:1 where noted; gaps are explicit.

Migrating from zustand-persist

Near 1:1 — this API is modeled on zustand persist, plus store-agnostic sources, a first-class HydrationSignal, and a codec seam.

| zustand-persist | @stainless-code/persist | | ------------------------------- | ---------------------------------------------------------------------------------- | | name | name | | storage / createJSONStorage | storage / createJSONStorage (core) | | partialize | partialize | | version | version | | migrate | migrate | | merge | merge | | onRehydrateStorage | onRehydrateStorage | | skipHydration | skipHydration | | persist middleware | persistStore(store, opts) or persistSource(source, opts) | | — | toHydrationSignal(persist) + useHydrated (no zustand equivalent) | | — | crossTab, maxAge, buster, throttleMs, retryWrite (no zustand equivalent) |

// zustand-persist
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";

export const usePrefs = create(
  persist(() => ({ theme: "light" }), {
    name: "app:prefs:v1",
    storage: createJSONStorage(() => localStorage),
  }),
);

// @stainless-code/persist
import { Store } from "@tanstack/store";
import { createJSONStorage, toHydrationSignal } from "@stainless-code/persist";
import { persistStore } from "@stainless-code/persist/sources/tanstack-store";
import { useHydrated } from "@stainless-code/persist/frameworks/react";

export const prefsStore = new Store({ theme: "light" });
const persist = persistStore(prefsStore, {
  name: "app:prefs:v1",
  storage: createJSONStorage(() => localStorage),
});
export const prefsHydration = toHydrationSignal(persist);
// const { hydrated } = useHydrated(prefsHydration);

Migrating from redux-persist

redux-persist reconciles whole reducer trees implicitly; here merge is explicit and a hydration signal gates UI until the snapshot lands. redux stores have getState/subscribe/dispatch (no setState), so wrap the store in a PersistableSource whose setState dispatches an action your reducer recognizes to replace state.

| redux-persist | @stainless-code/persist | | --------------------------------- | ------------------------------------------------------------------------ | | key | name | | storage | storage | | version | version | | migrate | migrate | | whitelist / blacklist | partialize (project the slice) | | transforms | merge (per-reducer in/out) | | serialize / deserialize | custom StorageCodec via createStorage | | stateReconciler | merge (default shallow-spread; customize) | | throttle | throttleMs | | persistReducer / persistStore | persistSource(reduxSource, opts) | | — | toHydrationSignal + framework adapter (no redux-persist equivalent) | | — | crossTab, maxAge, buster, retryWrite, schema validation (no eq.) |

// redux-persist
import { persistReducer, persistStore } from "redux-persist";
import storage from "redux-persist/lib/storage";

const persistedReducer = persistReducer({ key: "root", storage }, rootReducer);
export const store = createStore(persistedReducer);
persistStore(store);

// @stainless-code/persist — wrap the redux store (dispatch ↔ setState)
import {
  createJSONStorage,
  persistSource,
  toHydrationSignal,
} from "@stainless-code/persist";

const reduxSource = {
  getState: () => store.getState(),
  setState: (updater) =>
    store.dispatch({ type: "PERSIST_SET", payload: updater(store.getState()) }),
  subscribe: (listener) => store.subscribe(listener),
};
const persist = persistSource(reduxSource, {
  name: "root",
  storage: createJSONStorage(() => localStorage),
});
export const rootHydration = toHydrationSignal(persist);

Migrating from @tanstack/query-persist-client

query-persist-client owns the query cache lifecycle; here the seam is any PersistableSource — supply a cache-shaped source and compose storage like any other store.

| query-persist-client | @stainless-code/persist | | ----------------------------------------------- | ----------------------------------------------------------------------------------------- | | persister (Persister interface) | storage (PersistStoragegetItem/setItem/removeItem, or wrap createStorage) | | serialize / deserialize | StorageCodec via createStorage | | maxAge | maxAge | | buster | buster | | retry (shrink-or-give-up) | retryWrite | | throttleTime | throttleMs | | dehydrate / hydrate | partialize / merge | | persistQueryClient | persistSource(queryCacheSource, opts) | | useIsRestoring / PersistQueryClientProvider | toHydrationSignal + framework adapter (useHydrated/hydratedRune/hydratedStore) | | — | store-agnostic source, versioned migrate, crossTab, schema validation (no equivalent) |

// @tanstack/query-persist-client
import { persistQueryClient } from "@tanstack/query-persist-client";
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";

persistQueryClient({
  queryClient,
  persister: createSyncStoragePersister({ storage: window.localStorage }),
  maxAge: 1000 * 60 * 60 * 24,
  buster: BUILD_ID,
});

// @stainless-code/persist — supply a cache-shaped source
import {
  createJSONStorage,
  persistSource,
  toHydrationSignal,
} from "@stainless-code/persist";

const queryCacheSource = {
  getState: () => queryClient.getQueryCache().getAll(),
  setState: (entries) =>
    entries.forEach(({ queryKey, state }) =>
      queryClient.setQueryData(queryKey, state.data),
    ),
  subscribe: (cb) => queryClient.getQueryCache().subscribe(cb),
};
const persist = persistSource(queryCacheSource, {
  name: "query-cache",
  storage: createJSONStorage(() => localStorage),
  maxAge: 1000 * 60 * 60 * 24,
  buster: BUILD_ID,
});
export const queryHydration = toHydrationSignal(persist);

Migrating from pinia-persist

pinia-persist is a Pinia plugin; here persistence is a middleware call on any reactive source — same storage, explicit partialization, optional codec. Wrap the Pinia store's $state + $subscribe in a PersistableSource.

| pinia-persist | @stainless-code/persist | | -------------------------------- | ------------------------------------------------------------------------------------- | | key | name | | storage | storage | | paths | partialize (pick paths) | | serializer | custom StorageCodec or default jsonCodec via createStorage | | beforeHydrate / afterHydrate | onRehydrateStorage | | debug | onError | | pinia.use(plugin) | persistSource(piniaSource, opts) | | — | toHydrationSignal + framework adapter (no equivalent) | | — | crossTab, migrate, maxAge, buster, throttleMs, retryWrite (no equivalent) |

// pinia-persist
import { createPinia } from "pinia";
import piniaPluginPersistedstate from "pinia-plugin-persistedstate";

const pinia = createPinia();
pinia.use(
  piniaPluginPersistedstate({
    key: "prefs",
    storage: localStorage,
    paths: ["theme"],
  }),
);

// @stainless-code/persist — wrap $state / $subscribe
import {
  createJSONStorage,
  persistSource,
  toHydrationSignal,
} from "@stainless-code/persist";

const piniaSource = {
  getState: () => piniaStore.$state,
  setState: (updater) => {
    piniaStore.$state = updater(piniaStore.$state);
  },
  subscribe: (listener) => piniaStore.$subscribe(() => listener()),
};
const persist = persistSource(piniaSource, {
  name: "prefs",
  storage: createJSONStorage(() => localStorage),
  partialize: (s) => ({ theme: s.theme }),
});
export const prefsHydration = toHydrationSignal(persist);

Extensibility guide

Persistence middleware for any getState/setState/subscribe store (TanStack Store adapters included), built around three seams that make every "can it do X?" a one-line answer instead of a feature request. The full API contract lives in the JSDoc of each module.

Entry points (one subpath = one optional peer)

| Subpath | Symbols | Optional peer | | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | | @stainless-code/persist | persistSource, PersistApi, createStorage, createJSONStorage, createSessionStorage, createMigrationChain, jsonCodec, identityCodec, createPersistRegistry, toHydrationSignal, alwaysHydratedSignal | none | | @stainless-code/persist/codecs/seroval | serovalCodec, createSerovalStorage | seroval | | @stainless-code/persist/codecs/zod | zodCodec, createZodStorage | zod | | @stainless-code/persist/backends/idb | idbStateStorage, createIdbStorage (structured-clone mode) | idb-keyval | | @stainless-code/persist/backends/async-storage | asyncStorageStateStorage, createAsyncStorage | @react-native-async-storage/async-storage | | @stainless-code/persist/backends/mmkv | mmkvStateStorage, createMmkvStorage | react-native-mmkv | | @stainless-code/persist/backends/secure-store | secureStoreStateStorage, createSecureStoreStorage | expo-secure-store | | @stainless-code/persist/backends/encrypted | createEncryptedStorage (AES-GCM WebCrypto) | none (web global) | | @stainless-code/persist/backends/compressed | createCompressedStorage (gzip/deflate/deflate-raw) | none (web global) | | @stainless-code/persist/backends/node-fs | nodeFsStateStorage (one file per key) | none (Node built-in) | | @stainless-code/persist/transport/crosstab | createBroadcastCrossTab | none (web global) | | @stainless-code/persist/sources/tanstack-store | persistStore, persistAtom | @tanstack/store (types only) | | @stainless-code/persist/sources/zustand | persistStore | zustand | | @stainless-code/persist/sources/jotai | persistAtom | jotai | | @stainless-code/persist/sources/valtio | persistProxy | valtio | | @stainless-code/persist/sources/mobx | persistObservable | mobx | | @stainless-code/persist/frameworks/react | useHydrated React hook | react | | @stainless-code/persist/frameworks/solid | useHydrated (Solid Accessor<boolean>) | solid-js | | @stainless-code/persist/frameworks/vue | useHydrated (Vue Ref<boolean>) | vue | | @stainless-code/persist/frameworks/svelte | hydratedRune (Svelte 5 runes current) | svelte (>=5.7) | | @stainless-code/persist/frameworks/svelte-store | hydratedStore (Svelte Readable<boolean>) | svelte (>=3) | | @stainless-code/persist/frameworks/angular | useHydrated (Angular Signal<boolean>) | @angular/core (>=17) | | @stainless-code/persist/frameworks/preact | useHydrated (Preact { hydrated: boolean }) | preact (>=10.19) |

No barrel — importing a subpath is the dependency opt-in.

The three seams

1. Backend (StateStorage<TRaw = string>) — anything with getItem/setItem/removeItem, sync or Promise-returning, string-wire by default, generic for structured backends.

import { createSerovalStorage } from "@stainless-code/persist/codecs/seroval";
import { createIdbStorage } from "@stainless-code/persist/backends/idb";
import { createJSONStorage } from "@stainless-code/persist";

createSerovalStorage(() => localStorage); // durable prefs
createSerovalStorage(() => sessionStorage); // per-visit state (dies with the tab)
createIdbStorage(); // IndexedDB, structured-clone mode
createAsyncStorage(); // React Native AsyncStorage — ./backends/async-storage
createAsyncStorage(); // React Native AsyncStorage — async, useHydrated gating
createMmkvStorage({ id: "app-prefs" }); // React Native MMKV — sync, no gate needed
createSecureStoreStorage(); // expo-secure-store — OS keychain, ~2KB/key, for secrets
// custom: in-memory for tests, remote KV, encrypted wrapper — implement 3 methods

2. Codec (StorageCodec<S, TRaw = string>) — pure encode/decode between the persisted envelope and the backend's wire type.

import {
  jsonCodec,
  identityCodec,
  createStorage,
} from "@stainless-code/persist";
import { serovalCodec } from "@stainless-code/persist/codecs/seroval";
import { zodCodec } from "@stainless-code/persist/codecs/zod";
import { idbStateStorage } from "@stainless-code/persist/backends/idb";
import { z } from "zod";

const PrefsSchema = z.object({ theme: z.enum(["light", "dark"]) });

jsonCodec(); // core default — plain JSON
serovalCodec(); // Set / Map / Date / cycles, inert JSON-shaped output
zodCodec(PrefsSchema); // schema-gated persistence — invalid state never writes; corrupt reads discard
identityCodec(); // structured-clone backends only — zero serialization
// custom — any pair of pure functions:
const superjsonCodec = { encode: superjson.stringify, decode: superjson.parse }; // class instances via registerCustom
const encryptedCodec = {
  encode: (v) => encrypt(JSON.stringify(v)),
  decode: (raw) => JSON.parse(decrypt(raw)),
}; // sync cipher — for WebCrypto (async) use ./backends/encrypted

3. Store source (PersistableSource) — structural, so the middleware persists anything reactive:

import {
  persistStore,
  persistAtom,
} from "@stainless-code/persist/sources/tanstack-store";
import { persistSource } from "@stainless-code/persist";

persistStore(store, opts); // @tanstack/store Store
persistAtom(atom, opts); // writable Atom (replace-merge default)
persistSource({ getState, setState, subscribe }, opts); // zustand-like, redux, hand-rolled

Compose freely: createStorage(backend, codec, options) covers every backend × codec cell. Factory policy: codec factories take the backend as an argument; a backend earns its own factory only when it needs real adaptation (IndexedDB); everything else composes — no factory-per-combination.

Choosing a storage

Pick by sync-vs-async (does it gate UI?), cross-tab needs, and whether you want structured-clone (Set/Map/Date natively).

| Backend | Sync? | Cross-tab | Structured-clone | Size | Gate UI? | Subpath | | -------------------- | ----- | --------- | ---------------- | ------------------ | -------- | ------------------------------------------------- | | IndexedDB | ✗ | ✗ | ✓ | large | ✓ | ./backends/idb | | AsyncStorage (RN) | ✗ | ✗ | ✗ | large | ✓ | ./backends/async-storage | | MMKV (RN) | ✓ | ✗ | ✗ | large | ✗ | ./backends/mmkv | | Secure Store (Expo) | ✗ | ✗ | ✗ | ~2KB/key | ✓ | ./backends/secure-store | | Node fs | ✗ | ✗ | ✗ | disk | ✓ | ./backends/node-fs | | Encrypted (wrapper) | ✗ | ✗ | ✗ | inherits | ✓ | ./backends/encrypted | | Compressed (wrapper) | ✗ | ✗ | ✗ | inherits (smaller) | ✓ | ./backends/compressed | | localStorage | ✓ | ✓ | ✗ | ~5MB | ✗ | core createJSONStorage | | sessionStorage | ✓ | ✗ | ✗ | ~5MB | ✗ | core createJSONStorage / createSessionStorage |

IDB has no storage events — pair ./transport/crosstab for cross-tab sync. Encrypted/compressed wrappers over localStorage also don't receive native storage events (raw is the wrapper, not localStorage) — use ./transport/crosstab for cross-tab there too.

Choosing a codec

Pick by whether you need Set/Map/Date round-trips, schema-gated persistence, or a structured-clone backend. identityCodec only with structured-clone backends (IDB) — never string-wire.

| Codec | Set/Map/Date | Wire type | Schema validation | For backend | Subpath | | --------------------- | ----------------------------------------- | -------------------------- | ----------------- | --------------------------- | ------------------------ | | jsonCodec | ✗ | string | ✗ | string-wire | core | | serovalCodec | ✓ | string (JSON-shaped) | ✗ | string-wire | ./codecs/seroval | | zodCodec | ✗ (JSON wire; only via schema transforms) | string | ✓ | string-wire | ./codecs/zod | | identityCodec | ✓ (structured clone) | StorageValue<S> (object) | ✗ | structured-clone only (IDB) | core | | custom StorageCodec | yours | yours | yours | any | custom encode/decode |

Recipes

import { createStorage } from "@stainless-code/persist";
import {
  idbStateStorage,
  createIdbStorage,
} from "@stainless-code/persist/backends/idb";
import { createEncryptedStorage } from "@stainless-code/persist/backends/encrypted";
import { createCompressedStorage } from "@stainless-code/persist/backends/compressed";
import { serovalCodec } from "@stainless-code/persist/codecs/seroval";
import { persistStore } from "@stainless-code/persist/sources/tanstack-store";

// Encryption at rest (AES-GCM WebCrypto) over IndexedDB. Encryption is a
// backend wrapper (crypto.subtle is async, the StorageCodec seam is sync),
// not a sync codec; idbStateStorage() in string-wire mode holds the encrypted base64.
const key = await crypto.subtle.generateKey(
  { name: "AES-GCM", length: 256 },
  true,
  ["encrypt", "decrypt"],
);
createStorage(
  () => createEncryptedStorage(() => idbStateStorage(), { key })!,
  serovalCodec(),
  {
    clearCorruptOnFailure: true,
  },
);

// Compress-then-encrypt (standard order) — the two wrappers stack
createStorage(
  () =>
    createEncryptedStorage(
      () => createCompressedStorage(() => idbStateStorage())!,
      { key },
    )!,
  serovalCodec(),
);

// Legacy string payloads in IDB (written by an older version)
createStorage(() => idbStateStorage(), serovalCodec());

// Namespaced IDB store away from other idb-keyval users
import { createStore } from "idb-keyval";
createIdbStorage({ store: createStore("my-db", "persist") });

// Cross-tab sync (localStorage): pair crossTab with onCrossTabRemove when using skipPersist
persistStore(store, {
  name,
  storage,
  crossTab: true,
  onCrossTabRemove: () => store.actions.reset(),
});

// Cross-tab over IDB: no storage events — bridge a BroadcastChannel via crossTabEventTarget

Clear-all on logout

import { createPersistRegistry } from "@stainless-code/persist";
import { createSerovalStorage } from "@stainless-code/persist/codecs/seroval";
import { persistStore } from "@stainless-code/persist/sources/tanstack-store";

// One registry for every persisted store — clearAll() at logout wipes all keys
// (allSettled; first rejection rethrows; destroy() unregisters each store)
const registry = createPersistRegistry();
const storage = createSerovalStorage(() => localStorage);

persistStore(prefsStore, { name: "app:prefs", storage, registry });
persistStore(cartStore, { name: "app:cart", storage, registry });
persistStore(sessionStore, { name: "app:session", storage, registry });

async function logout() {
  await registry.clearAll();
}

Partialize

// Persist only prefs — ephemeral fields (scroll, modal) are excluded from the payload
persistStore(store, {
  name: "app:state",
  storage,
  partialize: (state) => state.prefs,
});

Merge

// Deep-merge nested settings on hydrate — default is shallow spread (persisted over current)
persistStore(store, {
  name: "app:settings",
  storage,
  merge: (persisted, current) => ({
    ...current,
    settings: {
      ...current.settings,
      ...(persisted as typeof current).settings,
    },
  }),
});

retryWrite — shrink-or-give-up on quota

// Quota exceeded: shrink state to retry, return undefined to give up.
// errorCount is the aggressiveness dial; stale retries never clobber newer state.
persistStore(store, {
  name: "app:history",
  storage,
  retryWrite: ({ state, errorCount }) => {
    if (errorCount === 1)
      return { ...state, history: state.history.slice(-50) };
    if (errorCount === 2) return { ...state, history: [] };
    return; // give up — last error goes to onError
  },
});

throttleMs — trailing throttle

// Coalesce a write burst into one trailing write with flush-time state; destroy() flushes pending
persistStore(store, {
  name: "app:canvas",
  storage,
  throttleMs: 250,
});

maxAge — payload expiry

// Discard payloads older than 7 days (by timestamp); missing timestamp = expired; key removed before migrate runs
const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000;

persistStore(store, {
  name: "app:draft",
  storage,
  maxAge: SEVEN_DAYS,
});

buster — cache-busting

// Format changed completely — bust stale keys instead of migrating wrong values (checked before migrate)
persistStore(store, {
  name: "app:layout",
  storage,
  buster: "grid-v2",
});

Migration chain

import { createMigrationChain } from "@stainless-code/persist";

// steps[N] takes vN → v(N+1). Start at a higher key to drop support for
// old versions (onOlder discards by default).
const migrate = createMigrationChain<Prefs>({
  version: 3,
  steps: {
    0: (s) => ({ ...s, theme: "light" }),
    1: (s) => ({ ...s, filters: [] }),
    2: (s) => ({ ...s, layout: "grid" }),
  },
});
persistStore(store, { name: "app:prefs:v3", version: 3, storage, migrate });

Wrapping your store

Every shipped source adapter is a thin persistSource wrapper — import the subpath, pass your store, wire storage. Naming is shape-based, not library-based (persistStore / persistAtom / persistProxy / persistObservable): same persistable shape → same name → same merge semantics, regardless of library; the subpath carries the library. Importing two same-shape adapters into one module? Alias one: import { persistStore as persistZustand } from "@stainless-code/persist/sources/zustand". Redux, signals, hand-rolled atoms: same seam — pass a custom PersistableSource to persistSource directly.

zustand

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 }));
const persist = persistStore(usePrefs, {
  name: "app:prefs:v1",
  storage: createJSONStorage(() => localStorage),
});

Or pass a custom PersistableSource to persistSource directly — the adapter is a thin wrapper over getState/setState/subscribe.

jotai

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");
const persist = persistAtom(store, themeAtom, {
  name: "app:theme:v1",
  storage: createJSONStorage(() => localStorage),
});

Or pass a custom PersistableSource to persistSource directly — the adapter is a thin wrapper over getState/setState/subscribe.

valtio

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 });
const persist = persistProxy(prefs, {
  name: "app:prefs:v1",
  storage: createJSONStorage(() => localStorage),
});

Or pass a custom PersistableSource to persistSource directly — the adapter is a thin wrapper over getState/setState/subscribe.

mobx

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 });
const persist = persistObservable(prefs, {
  name: "app:prefs:v1",
  storage: createJSONStorage(() => localStorage),
});

Or pass a custom PersistableSource to persistSource directly — the adapter is a thin wrapper over getState/setState/subscribe.

Any other store

import { createJSONStorage, persistSource } from "@stainless-code/persist";

const persist = persistSource(
  {
    getState: () => myStore.getState(),
    setState: (updater) => myStore.setState(updater),
    subscribe: (listener) => myStore.subscribe(() => listener()),
  },
  {
    name: "app:custom:v1",
    storage: createJSONStorage(() => localStorage),
  },
);

Cross-tab over IndexedDB

import { createBroadcastCrossTab } from "@stainless-code/persist/transport/crosstab";
import { createIdbStorage } from "@stainless-code/persist/backends/idb";

// IDB fires no storage events — bridge a BroadcastChannel as the transport.
// storageArea: null in every posted event → key-only matching in every tab.
const bridge = createBroadcastCrossTab({ channelName: "app:prefs" })!;
persistStore(store, {
  name: "app:prefs:v1",
  storage: bridge.wrap(createIdbStorage()!),
  crossTab: true,
  crossTabEventTarget: bridge.crossTabEventTarget,
});
// teardown: persist.destroy(); bridge.close();

Node fs (server / SSR / CLI)

import { createStorage } from "@stainless-code/persist";
import { nodeFsStateStorage } from "@stainless-code/persist/backends/node-fs";
import { serovalCodec } from "@stainless-code/persist/codecs/seroval";

// One file per key under ./.persist — async (fs.promises); gate UI on useHydrated in SSR
const storage = createStorage<Prefs>(
  () => nodeFsStateStorage({ dir: "./.persist" }),
  serovalCodec(),
);

Caveats that matter per backend: async backends (IDB) can't settle hydration before first paint → gate UI on useHydrated (@stainless-code/persist/frameworks/react); sessionStorage is per-tab (crossTab is meaningless); identityCodec never with string-only backends.

Writing a framework adapter

The React hook (@stainless-code/persist/frameworks/react) is ~20 lines over HydrationSignal — every adapter is the same shape. Solid (@stainless-code/persist/frameworks/solid, Accessor<boolean> via from), Vue (@stainless-code/persist/frameworks/vue, Ref<boolean> via shallowRef + onScopeDispose), and Svelte (@stainless-code/persist/frameworks/svelte runes hydratedRune; @stainless-code/persist/frameworks/svelte-store hydratedStore for Svelte 4 + Svelte 5 store users) ship the same way. The contract (full version on HydrationSignal's JSDoc): subscribe returns an idempotent unsubscribe; each subscribe call is an independent subscription; no initial notification and no payload — pull isHydrated() after attach and on every notification; transitions while detached aren't replayed (the snapshot re-read recovers); render hydrated: true on the server (no storage server-side); null signal = no persistence = hydrated.

import type { HydrationSignal } from "@stainless-code/persist";

// The shipped `./frameworks/svelte` adapter, in full — every adapter is this shape:
export function hydratedRune(signal: HydrationSignal | null) {
  if (!signal)
    return {
      get current() {
        return true;
      },
    };
  const subscribe = createSubscriber((update) =>
    signal.subscribeHydrated(update),
  );
  return {
    get current() {
      subscribe();
      return signal.isHydrated();
    },
  };
}

Lifecycle in one paragraph

persistSource hydrates on create (skip with skipHydration; rehydrate() is awaitable), subscribe-writes on every setState (gated until hydrated; optional trailing throttleMs), and tears down completely via destroy() — required for non-singleton stores. Failures route to onError with a phase (write/hydrate/migrate/crossTab); the console fallback is dev-only. Payloads carry version (→ migrate), timestamp (→ maxAge), and buster; retryWrite implements shrink-or-give-up on quota errors with a write-generation guard so stale retries never clobber newer state.

Compatibility

| Runtime | Supported range | | ------- | ----------------------- | | Node | ^20.19.0 || >=22.12.0 | | Bun | >=1.0.0 |

Optional peer ranges (frameworks, stores, codecs, backends) live in package.json peerDependencies — import only the subpaths you use.

FAQ

Why does my UI flash?
Async backend (IndexedDB, AsyncStorage, SecureStore, Node fs) without a hydration gate. Gate with your framework adapter — useHydrated, hydratedRune, or hydratedStore. Don't manually defer writes — the middleware already gates them until hydrated; double-gating drops legitimate updates.

IDB cross-tab isn't syncing
IndexedDB fires no storage events. Use @stainless-code/persist/transport/crosstab (createBroadcastCrossTab) as the crossTabEventTarget + bridge.wrap(...). See Cross-tab over IndexedDB.

Quota exceeded / storage full
retryWrite: ({ state, errorCount }) => ... — shrink to retry, undefined to give up. The write-generation guard ensures stale retries never clobber newer state. See retryWrite — shrink-or-give-up on quota.

Set / Map / Date don't round-trip
Use @stainless-code/persist/codecs/seroval for string-wire backends, or @stainless-code/persist/backends/idb (createIdbStorage — structured-clone, no codec). Never pair identityCodec with a string-only backend. See Choosing a codec.

How do I clear all persisted keys on logout?
createPersistRegistry() from core; pass registry to each store; await registry.clearAll(). See Clear-all on logout.

How do I encrypt at rest?
@stainless-code/persist/backends/encrypted (createEncryptedStorage) — wraps any backend with AES-GCM. See Recipes for compress-then-encrypt stacks.

The store is not a singleton — how do I clean up?
persist.destroy() on unmount (e.g. useEffect cleanup). See IndexedDB + React, end to end.

API reference

Full type-level reference is generated by TypeDoc — not hosted yet; build locally: bun run docs:api, then open docs/api/index.html. The authoritative contract for each entry is its JSDoc (hover in your editor).