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 storage events

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 null and 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, and save() 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 page