Persistence API
These examples use `createStore`:
createStore builds a store instance immediately and is still fully supported.
In application code prefer defineStore, which
returns an accessor you call — it is safe at module scope on a server and needs
no container passed around. Everything on this page applies to both.
QuantaJS persistence automatically saves and rehydrates store state with adapter-based storage.
Exports
import {
LocalStorageAdapter,
SessionStorageAdapter,
IndexedDBAdapter,
CookieAdapter,
type CookieAdapterOptions,
type PersistenceConfig,
type PersistenceAdapter,
type PersistedData,
type StoredState,
} from '@quantajs/core';
A store creates its persistence manager from the persist option; there is no separate factory.
PersistenceConfig<S>
S is the store's state, so include, exclude and transform.out are checked against it.
interface PersistenceConfig<S> {
adapter: PersistenceAdapter;
include?: ReadonlyArray<keyof S & string>; // default: the whole state
exclude?: ReadonlyArray<keyof S & string>;
version?: number; // default: 1
migrations?: Record<number, (data: StoredState) => StoredState>;
transform?: {
out?: (slice: Partial<S>) => StoredState; // before writing
in?: (data: StoredState) => StoredState; // after migrations, when loading
};
validator?: (data: StoredState) => boolean;
serialize?: (payload: PersistedData) => string; // default: JSON.stringify
deserialize?: (raw: string) => unknown; // default: a JSON parse that drops __proto__
debounceMs?: number; // default: 300
onError?: (error: Error, operation: 'read' | 'write' | 'remove' | 'watch-setup') => void;
}
// Data as stored. Values are unknown until your validator has checked them.
type StoredState = Record<string, unknown>;
// The envelope `serialize` encodes and the adapter stores.
interface PersistedData<D = StoredState> {
data: D;
version: number;
timestamp: number;
storeName: string;
}
Saving and loading, step by step
Save: pick the include / exclude keys → validator → transform.out → wrap in PersistedData → serialize → adapter.write.
Load: adapter.read → deserialize → refuse data from a newer version → strip prototype-pollution keys → migrations for each version in between → transform.in → keep only include / exclude keys → validator → apply to state.
The validator sees state-shaped data both ways: the slice before transform.out when saving, and the result of transform.in when loading. Only persisted keys are loaded, so removing a key from include stops its stored value from coming back.
Adapter Classes
LocalStorageAdapter
const adapter = new LocalStorageAdapter('app-store');
- Uses browser
localStorage - Supports cross-tab sync through
storageevents
SessionStorageAdapter
const adapter = new SessionStorageAdapter('session-store');
- Uses browser
sessionStorage - Session-scoped persistence
IndexedDBAdapter
const adapter = new IndexedDBAdapter('store-key', 'app-db', 'stores', 1);
- Async persistence for larger datasets
- Better fit for high-volume/local-first workloads
CookieAdapter
const adapter = new CookieAdapter('theme', {
path: '/', // default
maxAge: 60 * 60 * 24 * 30, // seconds; default 30 days
sameSite: 'Lax', // 'Lax' | 'Strict' | 'None'; default 'Lax'
secure: false, // required when sameSite is 'None'
domain: undefined, // optional
});
- Uses
document.cookie, so the value is sent to your server with each request - For small state: a write that would make the cookie longer than 4096 bytes throws, as do invalid options and blocked cookie access, and the store reports it through
onError - On the server it reads
nulland writes nothing; read the request's cookies yourself if the server needs the value - No cross-tab sync: cookies have no change event
Custom adapters
An adapter stores the string serialize produced:
interface PersistenceAdapter {
readonly key: string;
read(): string | null | Promise<string | null>;
write(data: string): void | Promise<void>;
remove(): void | Promise<void>;
// Optional: report writes from elsewhere, e.g. another tab; null when removed.
subscribe?(callback: (data: string | null) => void): () => void;
}
Throw from write when the data cannot be stored, such as when a quota is exceeded. The store catches it and reports it through onError.
Store-Level Usage
import { createStore, LocalStorageAdapter } from '@quantajs/core';
const userStore = createStore('user', {
state: () => ({
profile: null as null | { id: string; name: string },
theme: 'light' as 'light' | 'dark',
}),
persist: {
adapter: new LocalStorageAdapter('user-store-v1'),
version: 1,
include: ['profile', 'theme'],
debounceMs: 250,
},
});
Validation and Transform
const useSettings = defineStore('settings', {
state: () => ({ theme: 'light', lastSync: new Date(0) }),
persist: {
adapter: new LocalStorageAdapter('settings-v2'),
transform: {
// A Date does not survive JSON, so store it as a string...
out: (slice) => ({ ...slice, lastSync: slice.lastSync?.toISOString() }),
// ...and turn it back into a Date when loading.
in: (data) => ({ ...data, lastSync: new Date(String(data.lastSync)) }),
},
validator: (data) => data.theme === 'light' || data.theme === 'dark',
onError(error, operation) {
console.warn('Persistence error:', operation, error.message);
},
},
});
Migrations
Each migration is keyed by the version it produces and receives the data as stored under the previous one:
persist: {
adapter: new LocalStorageAdapter('app-store'),
version: 3,
migrations: {
// 1 → 2: add a property
2: (data) => ({ ...data, featureFlags: {} }),
// 2 → 3: rename one
3: ({ user, ...rest }) => ({ ...rest, currentUser: user }),
},
}
Data stored at version 1 runs through migrations[2] and then migrations[3]. Data from a newer version than the store's is refused rather than loaded.
PersistenceManager
When persistence is enabled, stores expose store.$persist:
interface PersistenceManager {
save(): Promise<void>;
load(): Promise<void>;
clear(): Promise<void>;
getAdapter(): PersistenceAdapter;
isRehydrated(): boolean;
destroy(): void;
}
store.$hydrated
await store.$hydrated;
A promise that resolves once restoration has finished. Prefer it over polling
$persist.isRehydrated() — it works whether or not persistence is configured
(resolving immediately when it is not), so calling code does not have to
branch.
// Before rendering anything that depends on restored state:
await useSettingsStore().$hydrated;
isRehydrated() still exists for a synchronous check.
Behavior Notes
- Rehydration is attempted automatically during store setup.
- Invalid/malformed payloads are safely rejected and routed to
onError. Dangerous keys (__proto__,constructor,prototype) are rejected on every ingest path — load, cross-tab sync, migrations and transforms. - Cross-tab sync checks
storageArea, contains parse failures, and refuses payloads claiming a newer schema version than the running code. clear()re-arms auto-save rather than disabling it for the rest of the session, andsave()always writes — both were fixed in 2.1.- Adapters are SSR-safe: on the server they degrade to a no-op instead of throwing from their constructor.
- Call
destroy()to clean up persistence watchers/listeners for disposed stores — or dispose the container, which does it for every store at once.
Spot something that needs improving?
Edit on GitHubEdit page