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.
Signature
Section titled “Signature”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}Parameters
Section titled “Parameters”| Param | Type | Description |
|---|---|---|
key | string | Storage key (namespace your app’s keys to avoid collisions). |
initial | T | Initial value used on first visit, on SSR, or when validation rejects. |
options | PersistedSignalOptions<T>? | See Options below. |
Returns
Section titled “Returns”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().
Quick start
Section titled “Quick start”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.
Options
Section titled “Options”storage
Section titled “storage”"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" });serialize / deserialize
Section titled “serialize / deserialize”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),});schema
Section titled “schema”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"); },});onSchemaFailure (since alpha.8)
Section titled “onSchemaFailure (since alpha.8)”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 } }); },});| Behaviour | Detail |
|---|---|
| Not invoked on first visit | raw would be null, not a failure. |
| Not invoked on storage errors | Private mode / disabled storage — environment fault, not schema fault. |
| Callback errors caught | If the callback throws, the exception is logged via console.warn. |
Behaviors
Section titled “Behaviors”- SSR-safe. On the server (
typeof window === "undefined"), returns a plain signal initialized toinitial, with no storage subscription. Hydration on the client picks up where the server left off. - Schema-validated. If the stored JSON is malformed or
schemathrows, the signal falls back toinitialrather than crashing at mount. TriggersonSchemaFailureif 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
persistedSignalat module scope in yourstores/file, not inside components. The write effect lives for the module lifetime by design — it has no disposal hook.
When to reach for it (and when not)
Section titled “When to reach for it (and when not)”| Use it for | Don’t use it for |
|---|---|
| Settings, theme, draft form data | Cache for server-side data (resource() is the better fit) |
| Todo lists, small in-memory caches | Anything > ~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 reload | Secrets, 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.
See also
Section titled “See also”- State Management guide → Persistence — the longer-form story including the inline hand-roll alternative for nested-signal stores.
/api/imports/#persistence— sub-path import reference.signal()— the underlying primitive.resource()— for server-derived data; can be paired withpersistedSignalas a freshness cache.- Persisted settings recipe — compose
persistedSignal<Settings>withbindPathfor single-record UIs (theme, filters, notification prefs).
Docs current to v0.1.0-alpha.9 . All releases →