@ilha/store
v0.7.7
Published
Shared reactive store for Ilha islands.
Maintainers
Readme
@ilha/store
Shared reactive store for Ilha islands — global state outside any single island, backed by alien-signals (the same engine as Ilha core).
Includes a /form subpath with unopinionated, type-safe form helpers built on Standard Schema — works with Zod, Valibot, ArkType, or any compatible library.
| API | Use in Ilha apps |
| ------------------------------ | --------------------------------------------------------------- |
| store.count() | Read state reactively inside html\` / JSX |
| **store.count(5)** | Write state directly — goes through middleware |
| **store.setState({ a, b })** | Atomic multi-key write — one commit, one re-render |
| **store.doubled()** | Read a derived value reactively (.loading/.error for async) |
| **store.increment()** | Invoke a named action |
| **store.select(s => …)** | Ad-hoc reactive projection (allocates a computed) |
| **store.bind(s => s.x)** | Two-way accessor for ilha bind:* directives |
| **store.subscribe(…)** | Imperative listener outside islands |
| **store.dispose()`** | Tear down effects for non-singleton (per-island) stores |
Installation
bun add @ilha/store ilhaWhen to Use
ilha state is island-local. Use @ilha/store when you need state that is:
- Shared across multiple islands — cart, auth session, theme
- Updated from outside an island — WebSocket handler, global event bus
- Persisted globally — synced to
localStorageviasubscribe - Form state — pair with
@ilha/store/formhelpers
For state only one island reads and writes, prefer ilha's built-in .state().
Quick Start
import { store } from "@ilha/store";
const counterStore = store({ count: 0, label: "counter" })
.derived("doubled", (ctx) => ctx.get().count * 2)
.middleware((patch, ctx, next) => {
// guard: floor count at zero
if (patch.count !== undefined && patch.count < 0) return;
next(patch);
})
.action("increment", (_, ctx) => ({ count: ctx.get().count + 1 }))
.action("decrement", (_, ctx) => ({ count: ctx.get().count - 1 }))
.action("setLabel", (label: string) => ({ label }))
.on("change", (state) => {
localStorage.setItem("counter", JSON.stringify(state));
})
.build();
counterStore.count(); // 0 — reactive read
counterStore.count(5); // write → goes through middleware
counterStore.doubled(); // 10 — reactive derived
counterStore.increment(); // 6
counterStore.setLabel("hits");
counterStore.setState({ count: 0, label: "reset" }); // atomic multi-key
counterStore.getState(); // { count: 0, label: "reset" }
counterStore.reset(); // restores to initial stateAPI
store(initialState) · store(schema)
Returns a StoreBuilder. Chain builder methods, then call .build() to get a live reactive store.
const s = store({ count: 0 }).build();
// explicit state type when inference is too wide
const typed = store<{ foo: string }>({ foo: "bar" }).build();Pass a Standard Schema (Zod, Valibot, ArkType, …) to validate every commit — accessor writes, setState, bind:*, and action patches. Initial state is parsed from the schema (.default() on the schema or its branches; outer .default() often needs undefined as a seed). Before validation, own keys with value undefined are omitted from the merged snapshot so partial action patches (e.g. { step: "verify" } only) do not fail unions because a stale optional field was left as undefined. Multi-step flows (login OTP, wizards): changing step re-validates with otp reset to "" and email kept. The verify branch must allow empty OTP until the user types, e.g. otp: z.union([z.literal(""), z.string().min(6).max(6)]) — not z.string().min(6) alone. Field accessors / bind:* skip full-schema validation while typing (draft email); use validateWithSchema in submit or setState when you need strict checks.
import { z } from "zod";
const s = store(
z.object({
email: z.email().default(""),
age: z.coerce.number().min(0).default(0),
}),
)
.onError(({ error, issues, patch }) => {
// invalid bind / setState — state is unchanged
})
.build();.build() throws if any key collides — state vs derived vs action vs built-in names (setState, reset, subscribe, select, bind, getState, getInitialState, dispose).
⚠️ Per-keystroke writes skip full-schema validation. State-key accessor writes (
s.email("dr")) andbind:*writes commit without running the schema, so drafts like a half-typed email stay in state — otherwise every keystroke short of a valid value would be rejected. This meansgetState()can hold schema-invalid state while the user is typing. Full validation runs onsetState, action patches, andreset. Validate explicitly at trust boundaries (submit handlers) withvalidateWithSchema(schema, store.getState()).
Builder methods
All builder methods are immutable — each returns a new StoreBuilder. The original is never mutated.
.derived(key, fn)
Registers a computed value. fn receives a context object; ctx.get() returns the current raw state.
const s = store({ price: 10, qty: 2 })
.derived("total", (ctx) => ctx.get().price * ctx.get().qty)
.build();
s.total(); // 20 — reactive read-onlyDerived accessors expose an envelope — ()/.value (current value), .loading, .error. For a synchronous derived, .loading is always false and .error always undefined, so you typically just call it.
Async derived — if fn is async (or returns a Promise), the derived runs reactively with an envelope, mirroring ilha core's .derived():
const userStore = store({ id: 1 })
.derived("user", async (ctx) => {
const res = await fetch(`/api/users/${ctx.get().id}`, { signal: ctx.signal });
return res.json() as Promise<User>;
})
.build();
userStore.user.loading; // true while fetching
userStore.user(); // User | undefined (the resolved value)
userStore.user.value; // same as user()
userStore.user.error; // Error | undefined if it rejected- It re-runs whenever a
ctx.get()dependency changes (e.g.userStore.id(2)refetches). - Re-runs abort the previous run via
ctx.signal, and stale resolutions are dropped — the last write wins. - The previous value stays visible (
.value) while.loadingistrueduring a refetch.
DerivedCtx is { get(), signal } and read-only — derived functions must stay pure (no writes).
Sync deriveds are evaluated once eagerly at .build() (to detect promise-returning functions); async deriveds start their first run at .build() too. Keep derived functions cheap and side-effect-free — don't assume they only run when read.
.action(key, fn)
Registers a named mutation. fn receives the props and a context object. Return a Partial patch to merge via setState, or return nothing when all writes go through ctx.set or the action is side-effect-only.
// Zero-arg action — omit or leave first param unannotated
.action("increment", (_, ctx) => ({ count: ctx.get().count + 1 }))
// Typed props — annotate the first parameter
.action("setLabel", (label: string) => ({ label }))ActionCtx exposes { get(), getInitial(), set(patch) }:
| Member | Use |
| ------------------ | ------------------------------------------------------------ |
| ctx.get() | Read the current state snapshot |
| ctx.getInitial() | Read the initial state (e.g. for reset-style actions) |
| ctx.set(patch) | Imperative write escape hatch for async / multi-step actions |
Every action on the built store exposes a reactive .pending flag — true while any async invocation of that action is in flight (sync actions never set it). Read it in a render for per-action loading UI:
const s = store({ user: null as User | null })
.action("save", async (_, ctx) => ({ user: await api.save(ctx.get().user) }))
.build();
ilha.render(() => html`<button disabled=${() => s.save.pending}>Save</button>`);Actions may be async; return Partial<TState>, void, or Promise of either (e.g. return void toast.error(...) after await). Patches from returned promises are applied when the promise settles; sync returns still commit immediately. Use navigate() (or your app router) for client redirects after a successful action — do not return redirect() from @ilha/router loaders inside a store action (it throws and is reported as source: "action").
The returned patch is the primary write path. Use ctx.set for multi-step writes inside an async action — you can return nothing when all updates go through ctx.set:
.action("load", (_, ctx) => {
ctx.set({ loading: true });
fetchUser().then((user) => ctx.set({ user, loading: false }));
}).middleware(fn)
Intercepts every state mutation before it commits. Multiple middlewares compose in registration order; call next(patch) to pass control to the next one. Applies to all write paths: accessor writes, setState, actions, and bind writes.
store({ count: 0 })
.middleware((patch, ctx, next) => {
console.log("before:", ctx.get().count, "→", patch.count);
next(patch);
})
.middleware((patch, _ctx, next) => {
if (patch.count !== undefined && patch.count < 0) return; // block negatives
next(patch);
});MiddlewareCtx exposes { get(), getInitial() }; ctx.get() returns the current pre-commit state.
.on(event, handler)
Registers a lifecycle listener. handler receives (nextState, prevState).
| Event | When it fires |
| ---------- | ------------------------------------------------ |
| "init" | Once, synchronously inside .build() |
| "change" | After every committed mutation (post-middleware) |
.onError(handler)
When the store was created with a Standard Schema, invalid commits are rejected (state unchanged). handler receives { error, source, patch?, path?, issues?, get() }. error is a StoreValidationError with .issues and .fieldErrors. Without .onError(), failures log to console.error.
store({ count: 0 })
.on("init", (state) => console.log("store ready", state))
.on("change", (state) => localStorage.setItem("s", JSON.stringify(state)))
.build();Built-in store methods
store.setState(patch)
Atomic multi-key write. One commit, one "change" event, one re-render. Routes through middleware.
s.setState({ a: 1, b: 2 }); // single commitstore.reset()
Resets the store to the initial state captured at .build() time. Routes through middleware; fires "change" only if the current state differs from the initial snapshot.
s.reset();store.dispose()
Tears the store down: stops all subscribe effects and async-derived effects, aborts in-flight async deriveds (via ctx.signal), and turns further writes into no-ops. Reads keep working on the last committed state. Idempotent.
Module-level singleton stores never need this. Call it when a store's lifetime is shorter than the page — e.g. a store created per island instance — otherwise its effects (and any refetching async deriveds) leak.
const draftStore = store({ text: "" })
.derived("preview", async (ctx) => renderPreview(ctx.get().text, ctx.signal))
.build();
// when the island unmounts:
draftStore.dispose();store.getState() / store.getInitialState()
Raw TState snapshots — no derived values, no actions. getInitialState() is frozen at .build() time.
store.subscribe(listener) / store.subscribe(selector, listener, options?)
Full-state and slice forms. Neither fires on initial subscription. Both return an unsubscribe function.
const unsub = s.subscribe((state, prev) => console.log(state, prev));
const unsub2 = s.subscribe((state) => state.count, (count, prev) => …);
unsub();Slice comparison defaults to Object.is, so an object-building selector (s => ({ a: s.a, b: s.b })) fires on every commit — it returns a fresh object each time. Pass { equal: shallowEqual } to compare one level deep instead:
import { shallowEqual } from "@ilha/store";
s.subscribe(
(st) => ({ a: st.a, b: st.b }),
(slice) => …,
{ equal: shallowEqual },
);store.select(selector) — reactive read accessor
Projects a slice into a () => S signal accessor. Each call allocates a fresh computed — hoist out of render functions.
const count = s.select((st) => st.count);
count(); // reactivestore.bind(selector) — two-way bind:* accessor
Returns a read/write accessor for ilha's bind:* template directives. Accepts property-path selectors only (s => s.user.name); derived expressions throw. Writes go through middleware but skip full-schema validation (see the callout under store(schema)) so in-progress input still commits.
const query = s.bind((st) => st.search.query);
query(); // read — reactive
query("ilha"); // write — goes through middlewareUsage with Ilha Islands
State and derived accessors on the built store are signal-shaped — they are typed as ilha SignalAccessor (including .select() on state keys) and carry [SIGNAL_ACCESSOR], so they work with Areia/ilha bind:* props (e.g. <Input bind:value={form.email} />). Use them inside .render(), .derived(), and .effect() without extra wrappers.
import { store } from "@ilha/store";
import ilha, { html } from "ilha";
const cartStore = store({ items: [] as string[] })
.action("add", (item: string, ctx) => ({ items: [...ctx.get().items, item] }))
.action("remove", (item: string, ctx) => ({ items: ctx.get().items.filter((i) => i !== item) }))
.derived("count", (ctx) => ctx.get().items.length)
.build();
// State and derived read directly as signal accessors — no .select() needed
export const CartBadge = ilha.render(() => html`<span>${cartStore.count()}</span>`);
export const CartList = ilha.render(
() =>
html`<ul>
${cartStore.items().map((item) => html`<li>${item}</li>`)}
</ul>`,
);Both islands stay in sync. CartBadge re-renders only when count changes; CartList only when items changes.
.select() — for ad-hoc projections
Use .select() when you need a projection that isn't worth a named .derived(). Hoist the call outside the render callback — each .select() allocates a computed.
const evenCount = cartStore.select((s) => s.items.length % 2 === 0);
// inside island:
ilha.render(() => html`<p>${evenCount()}</p>`);bind:* for form fields
const searchStore = store({ query: "", open: false }).build();
ilha.render(
() => html`
<dialog bind:open=${searchStore.bind((s) => s.open)}>
<input bind:value=${searchStore.bind((s) => s.query)} />
</dialog>
`,
);Persistence — persist(store, key, options?)
Keeps a store in sync with localStorage (or any getItem/setItem backend):
- Hydrates on call: reads
keyand merges the stored patch viasetState— middleware runs and schema stores validate, so corrupt or stale-shaped payloads are rejected instead of applied. - Writes through on every commit.
- Cross-tab sync: mirrors writes from other tabs via
storageevents (defaultlocalStoragebackend only; disable withcrossTab: false).
Returns an unsubscribe. No-op on the server.
import { store, persist } from "@ilha/store";
const cartStore = store({ items: [] as string[] }).build();
persist(cartStore, "cart");| Option | Default | Description |
| ------------- | --------------------- | ------------------------------------------- |
| storage | window.localStorage | Any { getItem, setItem } backend |
| crossTab | true | Apply storage events from other tabs |
| serialize | JSON.stringify | State → string |
| deserialize | JSON.parse | String → patch (validated on schema stores) |
Call the returned unsubscribe before store.dispose() for per-island stores.
URL persistence — persistQuery / querySpec / codec
persist's sibling for the query string (nuqs-style), from the @ilha/store/query entry point. Each state key maps to its own search param (?q=boots&page=2) — shareable, reload-safe, back/forward-friendly. Writes go through @ilha/router's navigate() (auto-detected, or injected via options.navigate) so loaders re-run; the URL seeds the store on init (schema-coerced/validated, invalid params degrade to defaults); back/forward syncs back into the store without echo loops. With omitDefaults (default), default-valued params are dropped from the URL.
First-paint read path (loader vs store)
persistQuery only seeds the store when it is called (typically island onMount). Island mount order is roughly: first render (store still at defaults) → onMount → persistQuery → applyFromUrl. Anything that must match the URL on first paint (filter chips, active pills, "N filters") should read the loader snapshot, not the store.
| Concern | Read from |
| ----------------------------------------- | --------------------------------------------------------- |
| Data (where, list) | loader / url.searchParams |
| Bound controls (bind:value) | store (after persistQuery) |
| Chrome that must match URL on first paint | loader via readQuery / querySpec.parse, not the store |
Shared definition — querySpec + stock codecs
Define codecs once; reuse in the loader and on the client:
import { z } from "zod";
import { store } from "@ilha/store";
import { codec, querySpec } from "@ilha/store/query";
const FilterSchema = z.object({
column: z.string(),
op: z.string(),
value: z.string(),
});
const spec = querySpec(
{ q: "", page: 1, f: [] as z.infer<typeof FilterSchema>[] },
{
params: {
page: codec.int({ min: 1, default: 1 }),
f: codec.json(),
},
debounce: { q: 250, f: 0 }, // per-key
history: { q: "replace", f: "push", page: "push" },
},
);
export const filters = store(
z.object({
q: z.string().default(""),
page: z.coerce.number().int().min(1).default(1),
f: z.array(FilterSchema).default([]),
}),
).build();
// loader (server or client) — isomorphic, no window
export const clientLoad = loader(({ url }) => {
const { q, page, f } = spec.parse(url);
return { q, page, f, rows: api.list({ q, page, f }) };
});
// island
export default ilha
.onMount(() => spec.persist(filters)) // return unsub as cleanup
.render(({ input }) => /* chips from input.f; bind:value={filters.q} */);Built-in codecs: codec.string, codec.int, codec.number, codec.bool, codec.stringArray, codec.json, codec.jsonb, codec.isoDate, codec.enum. Prefer these over String(array) (String([]) === "", String([{…}]) === "[object Object]"). Object/array keys without a codec get a dev warning and fall back to JSON.stringify.
Options
| Option | Default | Description |
| -------------- | -------------- | ---------------------------------------------------------------------------------------------------- |
| params | all state keys | Keys to persist: { q: "search" } renames, { tags: codec.stringArray() } adds a codec |
| history | "replace" | "replace", "push", per-key map { f: "push" }, or (changedKeys) => mode |
| debounce | 0 | Number or per-key map { q: 250, f: 0 }; a push flushes a pending debounced replace first |
| omitDefaults | true | Drop params whose value equals the store default |
| navigate | auto-detect | Injected URL writer; without @ilha/router, History API fallback (dev warning). Early writes queue. |
Lifecycle
Call from island onMount and return the unsubscribe as cleanup — do not call at module scope (SSR no-op / multi-tab weirdness) or leave dangling subscribers on remount (dev warns on double-subscribe). Singleton stores shared across routes should reset() on route param change or be keyed by route so stale f does not survive navigation.
Merge helpers — don't rebuild the query string from scratch
persistQuery preserves foreign params; app code that builds URLSearchParams from only page/sort will drop q/f. Use:
import { withQuery, storeHref } from "@ilha/store/query";
withQuery("/list?q=boots&tab=all", { page: 2 }); // → keeps q + tab
storeHref(filters, { page: 2 }, spec.persistOptions);
spec.href({ page: 2 }); // owned keys from defaults + patchReturns an unsubscribe (flushes any pending debounced write). No-op on the server — loaders should use spec.parse(url) / readQuery(store, url). See the store guide for the full filter-bar walkthrough.
SSR — dehydrate() / hydrate()
Stores are module-level singletons, so on a concurrent SSR server they must not be written during a render — request A's data would leak into request B. Instead, state travels the same way ilha island state does: serialized into the HTML, then seeded on the client.
dehydrate(storeOrState)→ JSON string. On a concurrent server pass a request-local object (e.g. loader data), not the shared store. Passing the store itself is fine in non-concurrent contexts (prerendering, tests).hydrate(store, raw)→ parses with the same guards as ilha's island snapshots (size cap, depth cap, must-be-plain-object, prototype-polluting keys stripped) and merges viasetState— middleware runs and schema stores validate, so corrupt payloads are rejected. Returnstruewhen the snapshot passes the parse/guard checks and is handed tosetState(schema validation may still reject the patch there),falsewhen it is ignored.
// server (inside the loader / request handler)
const payload = dehydrate({ items: cartItems }); // request-local data
// stamp into the shell, escaped for the embedding context:
// <script type="application/json" id="cart-state">${payload.replace(/</g, "\\u003c")}</script>// client — page island's onMount (runs on hydration)
import { hydrate } from "@ilha/store";
ilha.onMount(() => {
hydrate(cartStore, document.getElementById("cart-state")?.textContent);
});Forms — @ilha/store/form
import { extractFormData, validateWithSchema, issuesToErrors } from "@ilha/store/form";extractFormData(source) → Record<string, unknown>
Turns an HTMLFormElement or FormData into a plain object. Single fields stay scalar; repeated keys collapse to arrays; file inputs pass through as File values.
validateWithSchema(schema, data) → FormResult<T>
Runs a Standard Schema synchronously. Never throws. Returns { ok: true, data } or { ok: false, issues }. Use validateWithSchemaAsync for async schemas.
issuesToErrors(issues) → FormErrors
Flattens Standard Schema issues into a Record<string, string[]> keyed by dot-separated path. Form-level errors (no path) land under "".
preventDefault(fn) → handler
Wraps an ilha .on() callback so event.preventDefault() runs first, then your function receives the same context (event, state, target, …).
Full example
import { store } from "@ilha/store";
import {
extractFormData,
validateWithSchema,
issuesToErrors,
preventDefault,
} from "@ilha/store/form";
import type { FormErrors } from "@ilha/store/form";
import ilha, { html } from "ilha";
import { z } from "zod";
const ContactSchema = z.object({
name: z.string().min(1, "Name is required"),
email: z.email("Invalid email"),
});
const formStore = store({ errors: {} as FormErrors })
.action("submit", (event: SubmitEvent) => {
const result = validateWithSchema(
ContactSchema,
extractFormData(event.target as HTMLFormElement),
);
return { errors: result.ok ? {} : issuesToErrors(result.issues) };
})
.build();
export default ilha
.on(
"form@submit",
preventDefault(({ event }) => formStore.submit(event)),
)
.render(
() => html`
<form>
<input name="name" />
${formStore.errors().name ? html`<p role="alert">${formStore.errors().name![0]}</p>` : ""}
<input name="email" type="email" />
${formStore.errors().email ? html`<p role="alert">${formStore.errors().email![0]}</p>` : ""}
<button type="submit">Send</button>
</form>
`,
);TypeScript
import type {
StoreBuilder, // the builder type
BuiltStore, // the built store type
StateAccessor, // <T>: () => T and (value: T) => void
DerivedAccessor, // envelope accessor: () => T | undefined, .loading, .value, .error
DerivedValue, // { loading, value, error } — the derived envelope
DerivedCtx, // { get(), signal } — passed to .derived()
ActionCtx, // { get(), getInitial(), set() } — passed to .action()
MiddlewareCtx, // { get(), getInitial() } — passed to .middleware()
StoreBindable, // <S>: read/write accessor for bind:*
Listener, // (state, prevState) => void
SliceListener, // (slice, prevSlice) => void
Unsub, // () => void
} from "@ilha/store";
import type {
FormResult, // { ok: true, data } | { ok: false, issues }
FormErrors, // Record<string, string[]>
} from "@ilha/store/form";License
MIT
