Skip to content
Whisq v0.1.0-alpha.9

persistedSignal()

A Signal<T> backed by localStorage (or sessionStorage). Loads from storage on module init, writes on every change. SSR-safe, quota-safe, schema-validated. Use for small, human-readable JSON state — settings, draft form data, todo lists, theme preferences.

Shipped from the sub-path @whisq/core/persistence, not the main entry. The “no import-time I/O” rule for fetch() doesn’t apply here — localStorage reads are synchronous and local-only, so the read-once-at-module-load shape is the natural fit.

import { persistedSignal } from "@whisq/core/persistence";
import type { Signal } from "@whisq/core";
function persistedSignal<T>(
key: string,
initial: T,
options?: PersistedSignalOptions<T>,
): Signal<T>;
interface PersistedSignalOptions<T> {
storage?: "local" | "session";
serialize?: (value: T) => string;
deserialize?: (raw: string) => T;
schema?: (raw: unknown) => T;
onSchemaFailure?: (err: unknown, raw: string) => void; // since alpha.8
}
ParamTypeDescription
keystringStorage key (namespace your app’s keys to avoid collisions).
initialTInitial value used on first visit, on SSR, or when validation rejects.
optionsPersistedSignalOptions<T>?See Options below.

A standard Signal<T> — same .value / .peek() / .update() / .subscribe() surface. The persistence is invisible to consumers; reads and writes look exactly like a plain signal().

stores/todos.ts
import { persistedSignal } from "@whisq/core/persistence";
import { randomId } from "@whisq/core/ids";
interface Todo { id: string; text: string; done: boolean }
export const todos = persistedSignal<Todo[]>("todos", []);
// Mutate normally — every assignment persists.
export const addTodo = (text: string) => {
todos.value = [...todos.value, { id: randomId(), text, done: false }];
};

The first read on first visit returns [] (the initial); subsequent reads return whatever was last written.

"local" (default; persists across tabs and reloads) or "session" (per-tab, cleared when the tab closes). Both backends share the same SSR / quota / schema-validation behaviour.

const draft = persistedSignal<string>("draft", "", { storage: "session" });

Override the default JSON.stringify / JSON.parse for non-JSON storage formats — compressed strings, custom encodings, BigInt-aware serialization.

const big = persistedSignal<bigint>("big", 0n, {
serialize: (v) => v.toString(),
deserialize: (raw) => BigInt(raw),
});

Validate the deserialized value before adopting it. Return T on success; throw to reject and fall back to initial. Most useful for version migrations:

const settings = persistedSignal<Settings>("settings", DEFAULT_SETTINGS, {
schema: (raw) => {
// alpha shape had `theme: string`; current shape has `theme: { mode, accent }`.
if (typeof raw === "object" && raw && typeof (raw as any).theme === "string") {
return { ...(raw as object), theme: { mode: (raw as any).theme, accent: "blue" } } as Settings;
}
if (typeof raw === "object" && raw && typeof (raw as any).theme === "object") {
return raw as Settings;
}
throw new Error("settings: unrecognized stored shape");
},
});

Diagnostic hook fired synchronously before the helper falls back to initial when deserialize throws (malformed stored JSON) or schema throws (validator rejects). Receives the thrown error and the exact raw string read from storage.

const todos = persistedSignal<Todo[]>("todos", [], {
schema: validateTodosShape,
onSchemaFailure: (err, raw) => {
Sentry.captureException(err, { extra: { key: "todos", raw } });
},
});
BehaviourDetail
Not invoked on first visitraw would be null, not a failure.
Not invoked on storage errorsPrivate mode / disabled storage — environment fault, not schema fault.
Callback errors caughtIf the callback throws, the exception is logged via console.warn.
  • SSR-safe. On the server (typeof window === "undefined"), returns a plain signal initialized to initial, with no storage subscription. Hydration on the client picks up where the server left off.
  • Schema-validated. If the stored JSON is malformed or schema throws, the signal falls back to initial rather than crashing at mount. Triggers onSchemaFailure if set.
  • Quota-safe. If a write throws (QuotaExceededError, private-mode Safari), the helper logs a warning and keeps the in-memory value — the app keeps working.
  • Module-scope intent. Call persistedSignal at module scope in your stores/ file, not inside components. The write effect lives for the module lifetime by design — it has no disposal hook.
Use it forDon’t use it for
Settings, theme, draft form dataCache for server-side data (resource() is the better fit)
Todo lists, small in-memory cachesAnything > ~5 MB (the LocalStorage quota varies by browser)
Per-tab UI state (storage: "session")Cross-tab synchronization (no storage event handling)
State that survives a page reloadSecrets, auth tokens (LocalStorage is readable by every script on the origin)

For larger or structured data, reach for a dedicated IndexedDB library and wire its load/save into a regular signal() + effect() pair. The “module-scope read once, effect-write on change” shape still applies.

For server-derived state with a local cache, compose resource() with persistedSignal() — see recipe 3 in the persistence guide.

Docs current to v0.1.0-alpha.9 . All releases →