Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .agents/skills/docs-voice/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ was rejected) and the "when **not** to use it" anti-sell — keep both.
Section `meta.ts` icons and tab icons stay.
- **Adapter / listing order:** core → backends → codecs → sources → frameworks
→ transport. Within sources: tanstack-store → zustand → jotai → valtio →
mobx → custom. Within frameworks: react → preact → solid → angular → vue
svelte (runes → store).
mobx → pinia → redux → custom. Within frameworks: react → preact → solid →
angular → vue → svelte (runes → store).

## Don't

Expand Down
5 changes: 5 additions & 0 deletions .changeset/pinia-source-adapter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@stainless-code/persist": minor
---

Add first-party Pinia source adapter (`./sources/pinia`) with shape-named `persistStore` over `persistSource`.
5 changes: 5 additions & 0 deletions .changeset/redux-source-adapter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@stainless-code/persist": minor
---

Add first-party Redux source adapter (`./sources/redux`) with `persistStore` and companion `persistableReducer` (classic + RTK).
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

**Any store, any storage, one middleware — no flash.**

Hydration-aware persistence for any reactive store — no hydrate flash, no SSR mismatch. Store-agnostic via a structural `PersistableSource` (TanStack Store, zustand, jotai, valtio, mobx, or a hand-rolled atom); three composable seams (backend × codec × source) so you swap storage, serialization, or framework without rewriting. A first-class hydration signal gates UI on async backends; opt-in cross-tab sync, versioned migrations, encrypted/compressed backends, and retry-on-quota. Framework adapters for React, Solid, Vue, Svelte, Angular, and Preact.
Hydration-aware persistence for any reactive store — no hydrate flash, no SSR mismatch. Store-agnostic via a structural `PersistableSource` (TanStack Store, zustand, jotai, valtio, mobx, pinia, redux, or a hand-rolled atom); three composable seams (backend × codec × source) so you swap storage, serialization, or framework without rewriting. A first-class hydration signal gates UI on async backends; opt-in cross-tab sync, versioned migrations, encrypted/compressed backends, and retry-on-quota. Framework adapters for React, Solid, Vue, Svelte, Angular, and Preact.

[![core size](https://img.shields.io/size-limit/label/gzip/.size-limit.json/core/stainless-code/persist)](https://github.com/stainless-code/persist/blob/main/.size-limit.json)

Expand Down
2 changes: 2 additions & 0 deletions apps/docs/content/concepts/entry-points.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ One subpath = one optional peer. No barrel — importing a subpath is the depend
| `@stainless-code/persist/sources/jotai` | `persistAtom` | `jotai` |
| `@stainless-code/persist/sources/valtio` | `persistProxy` | `valtio` |
| `@stainless-code/persist/sources/mobx` | `persistObservable` | `mobx` |
| `@stainless-code/persist/sources/pinia` | `persistStore` | `pinia` |
| `@stainless-code/persist/sources/redux` | `persistStore`, `persistableReducer` | `redux` |
| `@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` |
Expand Down
101 changes: 37 additions & 64 deletions apps/docs/content/guides/migrating.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -52,22 +52,22 @@ export const prefsHydration = toHydrationSignal(persist);

## 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 owns writes inside `persistReducer`. Here wrap the root reducer with `persistableReducer` (not each slice) and call `persistStore`. Redux has no `setState`; RTK slices ignore foreign hydrate actions otherwise. Alias if you still import redux-persist's `persistStore`.

| 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` | `persistableReducer` + `persistStore` from `./sources/redux` |
| — | `toHydrationSignal` + framework adapter (no redux-persist equivalent) |
| — | `crossTab`, `maxAge`, `buster`, `retryWrite`, schema validation (no eq.) |

```ts
// redux-persist
Expand All @@ -79,38 +79,25 @@ const persistedReducer = persistReducer({ key: "root", storage }, rootReducer);
export const store = createStore(persistedReducer);
persistStore(store);

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

export const store = createStore(rootReducer); // rootReducer handles PERSIST_SET
const persist = persistSource(
{
getState: () => store.getState(),
setState: (updater) =>
store.dispatch({
type: "PERSIST_SET",
payload: updater(store.getState()),
}),
subscribe: (listener) => ({
unsubscribe: store.subscribe(listener),
}),
},
{
name: "root",
storage: createJSONStorage(() => localStorage),
},
);
persistStore,
persistableReducer,
} from "@stainless-code/persist/sources/redux";

export const store = createStore(persistableReducer(rootReducer));
const persist = persistStore(store, {
name: "root",
storage: createJSONStorage(() => localStorage),
});
export const rootHydration = 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 is a Pinia plugin; here call `persistStore` on the store — same storage, `partialize`, optional codec.

| pinia-persist | `@stainless-code/persist` |
| -------------------------------- | ------------------------------------------------------------------------------------- |
Expand All @@ -120,7 +107,7 @@ pinia-persist is a Pinia plugin; here persistence is a middleware call on any re
| `serializer` | custom `StorageCodec` or default `jsonCodec` via `createStorage` |
| `beforeHydrate` / `afterHydrate` | `onRehydrateStorage` |
| `debug` | `onError` |
| `pinia.use(plugin)` | `persistSource(piniaSource, opts)` |
| `pinia.use(plugin)` | `persistStore(store, opts)` from `./sources/pinia` |
| — | `toHydrationSignal` + framework adapter (no equivalent) |
| — | `crossTab`, `migrate`, `maxAge`, `buster`, `throttleMs`, `retryWrite` (no equivalent) |

Expand All @@ -136,33 +123,19 @@ export const usePrefsStore = defineStore("prefs", {
persist: { key: "prefs", paths: ["theme"] },
});

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

export const usePrefsStore = defineStore("prefs", {
state: () => ({ theme: "light", draft: "" }),
});
const prefsStore = usePrefsStore();
const persist = persistSource(
{
getState: () => prefsStore.$state,
setState: (updater) => {
prefsStore.$patch(updater(prefsStore.$state));
},
subscribe: (listener) => ({
unsubscribe: prefsStore.$subscribe(() => listener()),
}),
},
{
name: "prefs",
storage: createJSONStorage(() => localStorage),
partialize: (s) => ({ theme: s.theme }),
},
);
const persist = persistStore(prefsStore, {
name: "prefs",
storage: createJSONStorage(() => localStorage),
partialize: (s) => ({ theme: s.theme }),
});
export const prefsHydration = toHydrationSignal(persist);
```
2 changes: 1 addition & 1 deletion apps/docs/content/recipes/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Composable patterns over the three seams. Start with [Composition](/recipes/comp
| ------------------------------------------- | ------------------------------------------------------------------------------------------ |
| [Composition](/recipes/composition) | Encrypt, compress, legacy IDB string payloads, namespaced stores |
| [Options](/recipes/options) | Clear-all, partialize, merge, retryWrite, throttleMs, maxAge, buster, migrate, skipPersist |
| [Wrapping stores](/recipes/wrapping-stores) | zustand, jotai, valtio, mobx, custom `PersistableSource` |
| [Wrapping stores](/recipes/wrapping-stores) | zustand, jotai, valtio, mobx, pinia, redux, custom `PersistableSource` |
| [Cross-tab](/recipes/crosstab) | localStorage sync and BroadcastChannel over IndexedDB |
| [Zod](/recipes/zod) | Schema-gated persist with `createZodStorage` |
| [React Native](/recipes/react-native) | AsyncStorage, MMKV, Expo Secure Store |
Expand Down
39 changes: 37 additions & 2 deletions apps/docs/content/recipes/wrapping-stores.mdx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
---
title: Wrapping stores
description: Persist zustand, jotai, valtio, mobx, or any custom PersistableSource with thin source adapters.
description: Persist zustand, jotai, valtio, mobx, pinia, redux, or any custom PersistableSource with thin source adapters.
search:
tags: ["recipe", "zustand", "jotai", "valtio", "mobx"]
tags: ["recipe", "zustand", "jotai", "valtio", "mobx", "pinia", "redux"]
---

Same `getState`/`setState`/`subscribe` shape → same adapter name → same merge semantics, regardless of library. Naming is shape-based (`persistStore` / `persistAtom` / `persistProxy` / `persistObservable`); 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"`. Escape hatch: pass a custom `PersistableSource` to `persistSource` directly.
Expand Down Expand Up @@ -72,6 +72,41 @@ const persist = persistObservable(prefs, {

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

## pinia

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

## redux

Wrap the root reducer with `persistableReducer` (not each slice) so hydrate can replace state. Alias if you also import redux-persist's `persistStore`.

```ts
import { createStore } from "redux";
import { createJSONStorage } from "@stainless-code/persist";
import {
persistStore,
persistableReducer,
} from "@stainless-code/persist/sources/redux";

const store = createStore(persistableReducer(rootReducer));
const persist = persistStore(store, {
name: "app:root:v1",
storage: createJSONStorage(() => localStorage),
});
```

## Any other store

```ts
Expand Down
6 changes: 6 additions & 0 deletions apps/docs/content/reference/api/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,12 @@ One TypeDoc module per package entry. [Reference](/reference)
<Card title="MobX" href="/reference/api/adapters-sources-mobx" arrow>
`@stainless-code/persist/sources/mobx`
</Card>
<Card title="Pinia" href="/reference/api/adapters-sources-pinia" arrow>
`@stainless-code/persist/sources/pinia`
</Card>
<Card title="Redux" href="/reference/api/adapters-sources-redux" arrow>
`@stainless-code/persist/sources/redux`
</Card>
</CardGroup>

## Frameworks
Expand Down
2 changes: 2 additions & 0 deletions apps/docs/content/reference/api/meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export default defineMeta({
"adapters-sources-jotai",
"adapters-sources-valtio",
"adapters-sources-mobx",
"adapters-sources-pinia",
"adapters-sources-redux",
"adapters-frameworks-react",
"adapters-frameworks-solid",
"adapters-frameworks-vue",
Expand Down
4 changes: 3 additions & 1 deletion apps/docs/pages/_home/Seams.astro
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ const sources = [
"Jotai",
"Valtio",
"MobX",
"custom source",
"Pinia",
"Redux",
"Any source",
];

const frameworks = [
Expand Down
39 changes: 38 additions & 1 deletion apps/docs/pages/_home/source-snippets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export interface SourceSnippet {

export const defaultSourceId = "tanstack-store";

/** Order: tanstack-store → zustand → jotai → valtio → mobx → custom */
/** Order: tanstack-store → zustand → jotai → valtio → mobx → pinia → redux → custom */
export const sourceSnippets: SourceSnippet[] = [
{
id: "tanstack-store",
Expand Down Expand Up @@ -90,6 +90,43 @@ const prefs = observable.object({ theme: "light" as const });
persistObservable(prefs, {
name: "app:prefs:v1",
storage: createJSONStorage(() => localStorage),
});`,
},
{
id: "pinia",
label: "Pinia",
icon: "/brands/pinia.svg",
lang: "ts",
installCommand: "bun add @stainless-code/persist pinia",
code: `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),
});`,
},
{
id: "redux",
label: "Redux",
icon: "/brands/redux.svg",
lang: "ts",
installCommand: "bun add @stainless-code/persist redux",
code: `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),
});`,
},
{
Expand Down
Loading
Loading