State Management
Whisq doesn’t need a state management library. Shared state is just signals and functions exported from a module. Any component that imports them stays in sync automatically.
The Store Pattern
Section titled “The Store Pattern”Create a file that exports signals and functions:
import { signal, computed } from "@whisq/core";
export const count = signal(0);export const doubled = computed(() => count.value * 2);export const increment = () => { count.value++; };export const decrement = () => { count.value--; };export const reset = () => { count.value = 0; };Use it in any component:
import { component, div, button, span } from "@whisq/core";import { count, doubled, increment, decrement } from "./stores/counter";
const Display = component(() => div( span(() => `Count: ${count.value}`), span(() => ` (doubled: ${doubled.value})`), ));
const Controls = component(() => div( button({ onclick: decrement }, "-"), button({ onclick: increment }, "+"), ));Both components share the same state. When increment() is called in Controls, the Display updates automatically.
A Real-World Store
Section titled “A Real-World Store”A todo store with add, remove, toggle, and derived state:
import { signal, computed } from "@whisq/core";import { randomId } from "@whisq/core/ids";
interface Todo { id: string; text: string; done: boolean;}
export const todos = signal<Todo[]>([]);export const remaining = computed(() => todos.value.filter(t => !t.done).length);
export const addTodo = (text: string) => { todos.value = [...todos.value, { id: randomId(), text, done: false }];};
export const toggleTodo = (id: string) => { todos.value = todos.value.map(t => t.id === id ? { ...t, done: !t.done } : t );};
export const removeTodo = (id: string) => { todos.value = todos.value.filter(t => t.id !== id);};Local vs Shared State
Section titled “Local vs Shared State”Local state lives inside a component. Use it for UI state that only that component needs:
import { signal, component, div, button, when } from "@whisq/core";
const Accordion = component(() => { const open = signal(false);
return div( button({ onclick: () => open.value = !open.value }, "Toggle"), when(() => open.value, () => div("Content here")), );});Shared state lives in a store module. Use it when multiple components need the same data:
import { signal, computed } from "@whisq/core";
export const user = signal<{ name: string } | null>(null);export const isLoggedIn = computed(() => !!user.value);export const logout = () => { user.value = null; };Mutations as named actions
Section titled “Mutations as named actions”Nothing in the framework forces this — filter.value = "all" works exactly the same from any call site. But past the single-screen threshold, write every mutation as an exported helper function from the store file, and import both signals and actions from the same module. The rule:
Store files export (a) the signals and computeds that describe state, (b) the action functions that change it. Consumers import both; mutation happens only inside the store module.
// ❌ inline mutation at the call site — fine for a single-screen demo,// trouble once state is owned by more than one componentbutton({ onclick: () => (filter.value = "all") }, "All");
// ✅ named action — single place where this mutation can originate// stores/filters.tsexport const filter = signal<"all" | "active" | "done">("all");export const setFilter = (next: "all" | "active" | "done") => { filter.value = next;};
// call sitebutton({ onclick: () => setFilter("all") }, "All");Why it matters past the single-screen threshold:
- Auditable. The set of ways state can change is the set of exported action functions. A newcomer reading
stores/filters.tssees the full API. - One place to hook. Instrumentation, logging, undo/redo, server sync — all plug into the named action, not into N scattered
signal.value =sites. - Validation / invariants.
addTodo(text)can trim, validate, reject empty — a directtodos.value.push(...)has no place for that. - Refactoring. Changing the underlying signal shape (plain boolean → nested signal, scalar →
persistedSignal, etc.) touches one file.
The todo-app example keeps mutations inline (filter.value = "all" in onclick) for readability — a beginner can trace the state change without tab-switching between files. For anything larger, the named-action pattern saves every team from re-deriving it.
Cross-reference: packages/core/docs/project-structure.md section on store-file layout states the same rule from the file-structure angle (“each file exports the signals plus the mutation helpers that operate on them”).
Persistence (localStorage)
Section titled “Persistence (localStorage)”The project-structure rules say no import-time network calls — they break SSR, tests, and first-paint. localStorage is different: reads are synchronous, there’s no network, and the “read once at module load, write on every change” shape is the natural fit for Whisq’s reactive model.
@whisq/core ships persistedSignal(key, initial, options?) on the @whisq/core/persistence sub-path — a Signal<T> backed by localStorage (or sessionStorage). All the SSR / quota / corrupt-JSON guards are handled inside the helper. See /api/persistedsignal/ for the full options surface and behaviour reference.
See all @whisq/* entry points for the complete sub-path list.
import { computed } from "@whisq/core";import { persistedSignal } from "@whisq/core/persistence";import { randomId } from "@whisq/core/ids";
export const todos = persistedSignal<Todo[]>("todos", []);export const remaining = computed(() => todos.value.filter((t) => !t.done).length);
// Mutate normally — every assignment persists.export const addTodo = (text: string) => { todos.value = [...todos.value, { id: randomId(), text, done: false }];};Lives on a sub-path so apps that don’t need it pay no bundle cost.
Recipes by data shape
Section titled “Recipes by data shape”Three patterns cover the persistence story end-to-end. Pick by what your data looks like.
Recipe — plain-object state
Section titled “Recipe — plain-object state”The default. Use when items are JSON-serializable end-to-end (plain booleans, numbers, strings, nested plain objects). Most apps want this.
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", []);
// Optional: validate stored shape and surface diagnostics on bad payloads.export const settings = persistedSignal<Settings>("settings", DEFAULT_SETTINGS, { schema: validateSettingsShape, onSchemaFailure: (err, raw) => { Sentry.captureException(err, { extra: { key: "settings", raw } }); },});Add new entries with randomId() from @whisq/core/ids (UUID-v4 shape, native crypto preferred). The schema + onSchemaFailure pair is the canonical “validate or fall back to initial, log to Sentry” shape — see /api/persistedsignal/ for the full option surface.
Recipe — nested signals (per-item reactive isolation)
Section titled “Recipe — nested signals (per-item reactive isolation)”When per-item field changes are frequent and you want the array signal to stay quiet on toggle, hold each per-row state in its own Signal. JSON.stringify can’t round-trip a Signal, so use a hand-rolled loadTodos() + persist-effect() pair.
import { signal, effect, type Signal } from "@whisq/core";
export interface Todo { id: string; text: string; done: Signal<boolean> }interface StoredTodo { id: string; text: string; done: boolean }
const STORAGE_KEY = "whisq.todos";
function loadTodos(): Todo[] { if (typeof localStorage === "undefined") return []; try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return []; const parsed = JSON.parse(raw) as StoredTodo[]; return parsed.map((t) => ({ id: t.id, text: t.text, done: signal(t.done) })); } catch { return []; // corrupt payload — start fresh }}
export const todos = signal<Todo[]>(loadTodos());
// Re-runs whenever todos.value OR any per-item .done.value changes.effect(() => { const list = todos.value; if (typeof localStorage === "undefined") return; try { const serializable = list.map((t) => ({ id: t.id, text: t.text, done: t.done.value })); localStorage.setItem(STORAGE_KEY, JSON.stringify(serializable)); } catch { // Quota exceeded or serialization failure — degrade silently. }});This is the shape the Todo App example uses. Pay the boilerplate when narrow per-item reactivity is worth more than the helper-driven 3-liner.
Recipe — remote source of truth + local cache
Section titled “Recipe — remote source of truth + local cache”When the data lives on a server and localStorage is a freshness cache. resource() owns the fetch lifecycle; persistedSignal warms the next page-load.
import { resource } from "@whisq/core";import { persistedSignal } from "@whisq/core/persistence";
interface User { id: string; name: string }
const cache = persistedSignal<User[]>("users.cache", []);export const users = resource(async () => { const fresh = await fetch("/api/users").then((r) => r.json() as Promise<User[]>); cache.value = fresh; // warm the cache for next page-load return fresh;}, { initialValue: cache.value }); // hydrate immediately from cacheThe initialValue: cache.value line is what makes first-paint instant — the page renders cached data before the network round-trip finishes, then re-renders when fresh data arrives. See Data Fetching for resource()’s full lifecycle.
Recipe — persisted settings (single-record + bindPath)
Section titled “Recipe — persisted settings (single-record + bindPath)”When the data is a single settings record with nested fields — theme, filter, notification prefs — and your UI binds directly into those fields. Compose persistedSignal<Settings> for the storage half with bindPath from @whisq/core/forms for the per-field binding.
import { persistedSignal } from "@whisq/core/persistence";
interface Settings { filter: "all" | "active" | "done"; theme: { mode: "light" | "dark"; accent: "blue" | "purple" | "green" }; notifications: { email: boolean; push: boolean };}
const DEFAULT: Settings = { filter: "all", theme: { mode: "light", accent: "blue" }, notifications: { email: true, push: false },};
export const settings = persistedSignal<Settings>("app.settings", DEFAULT);Call sites use bindPath to write into specific leaves — each bindPath write produces a new root and new objects along the path only, so unchanged branches keep their reference identity:
import { form, label, select, option, input } from "@whisq/core";import { bindPath } from "@whisq/core/forms";import { settings } from "../stores/settings";
form( label("Filter", select({ ...bindPath(settings, ["filter"]) }, option({ value: "all" }, "All"), option({ value: "active" }, "Active"), option({ value: "done" }, "Done"), ), ), label("Theme", select({ ...bindPath(settings, ["theme", "mode"]) }, option({ value: "light" }, "Light"), option({ value: "dark" }, "Dark"), ), ), label("Email notifications", input({ type: "checkbox", ...bindPath(settings, ["notifications", "email"], { as: "checkbox" }) }), ),);Closes the three-primitive binding story: bind for one signal, bindField for a field in an array, bindPath for a nested object field — now in a persisted shape. persistedSignal handles serialization; the per-path writes preserve reference identity on unchanged branches so dependent computed / effect only re-run when their branch changed.
Options
Section titled “Options”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}-
storage—"local"(default; persists across tabs and reloads) or"session"(per-tab, cleared when the tab closes). -
serialize/deserialize— override the defaultJSON.stringify/JSON.parsefor non-JSON storage formats (compressed strings, custom encodings). -
onSchemaFailure(since alpha.8) — diagnostic hook fired synchronously before the helper falls back toinitialwhendeserializethrows (malformed stored JSON) orschemathrows (validator rejects). Receives the thrown error and the exact raw string read from storage. Use it to log to Sentry, show a recovery UI, or decide between migrate-vs-reset:const todos = persistedSignal<Todo[]>("todos", [], {schema: validateTodosShape,onSchemaFailure: (err, raw) => {Sentry.captureException(err, { extra: { key: "todos", raw } });},});Behaviour notes:
- Not invoked on first-visit —
rawwould benull, not a failure. - Not invoked on storage-access errors (private mode, disabled storage) — those are environment faults, not schema faults; the existing quota-safe path handles them.
- Callback errors are caught. If the callback itself throws, the exception is caught and logged via
console.warnso a broken diagnostic pipeline can’t prevent signal construction.
- Not invoked on first-visit —
-
schema— validate the deserialized value before adopting it. Throw to reject and fall back toinitial(and triggeronSchemaFailureif set). 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;}// Already-current shape: validate, then accept.if (typeof raw === "object" && raw && typeof (raw as any).theme === "object") {return raw as Settings;}// Unrecognized shape (a stray string from a buggy build, primitives,// anything else) — throw so the helper falls back to DEFAULT_SETTINGS.throw new Error("settings: unrecognized stored shape");},});
Documented behaviors
Section titled “Documented behaviors”- SSR-safe. On the server (
typeof window === "undefined") returns a plain signal initialized toinitial, with no storage subscription. - Schema-validated. If the stored JSON is malformed or
schemathrows, the signal falls back toinitialrather than crashing at mount. - 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.
What’s still forbidden
Section titled “What’s still forbidden”The “no import-time I/O” rule still applies to anything network-shaped. Keep fetch(), WebSocket connections, and analytics boot calls out of module scope — use resource() or a user-triggered action. localStorage is the specific, blessed exception because it’s local and synchronous.
IndexedDB and larger stores
Section titled “IndexedDB and larger stores”persistedSignal is sized for small, human-readable JSON state. 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.
Batch Updates
Section titled “Batch Updates”When updating multiple signals at once, wrap them in batch() to avoid intermediate renders:
import { signal, batch } from "@whisq/core";
const firstName = signal("");const lastName = signal("");const age = signal(0);
const loadProfile = (profile: { first: string; last: string; age: number }) => { batch(() => { firstName.value = profile.first; lastName.value = profile.last; age.value = profile.age; }); // UI updates once, not three times};Composing Stores
Section titled “Composing Stores”Stores can import from other stores:
import { signal, computed } from "@whisq/core";import { user } from "./auth";
export const items = signal<{ name: string; price: number; qty: number }[]>([]);export const total = computed(() => items.value.reduce((sum, item) => sum + item.price * item.qty, 0));export const canCheckout = computed(() => !!user.value && items.value.length > 0);Next Steps
Section titled “Next Steps”- Signals — How reactivity works under the hood
- Components — Building component trees
- Data Fetching — Loading server data into stores
Docs current to v0.1.0-alpha.9 . All releases →