Store
@ilha/store is shared reactive state for ilha apps. It sits outside any island and uses alien-signals — the same engine as island .state() — so stores and islands share one reactive graph without bridging.
The @ilha/store/form import path adds small helpers on Standard Schema (Zod, Valibot, ArkType, and others). See Forms below.
Island .state() is local to one component. Use @ilha/store when state must be shared across islands or updated from non-island code.
Install
ilha is an optional peer dependency. Install both so bind:* directives and signal tracking work in templates.
Import paths
| Import path | Use it for |
|---|---|
@ilha/store | store, store types, subscriptions, select, bind, and effectScope |
@ilha/store/form | Form extraction, validation, issue-to-error mapping, preventDefault for .on() handlers |
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.getState(); // { count: 6, label: "counter" }
When to use
ilha's built-in .state() is the right choice when only one island reads and writes a piece of state. Use @ilha/store when state needs to be:
- Shared across multiple islands — e.g. a cart, auth session, or active theme
- Updated from outside an island — e.g. from a WebSocket handler or global event bus
- Persisted or derived globally — e.g. synced to
localStoragevia.on("change", …) - Form state — pair with
@ilha/store/formhelpers for typed validation and error mapping
API
store(initialState)
Returns a StoreBuilder. Chain builder methods then call .build() to get a live reactive store. .build() throws if any key collides across state, derived, actions, and built-in method names.
const s = store({ count: 0 }).build();
Builder methods are immutable — each returns a new StoreBuilder.
.derived(key, fn)
Registers a computed value. fn receives ctx; ctx.get() returns the current raw state.
store({ count: 0 })
.derived("doubled", (ctx) => ctx.get().count * 2)
.build();
Derived accessors expose an envelope — ()/.value, .loading, .error. For sync deriveds, .loading is always false.
Async derived — fn can be async. It re-runs when its ctx.get() dependencies change, aborts stale runs via ctx.signal, and surfaces the async lifecycle:
const userStore = store({ id: 1 })
.derived("user", async (ctx) => {
const res = await fetch(`/api/users/${ctx.get().id}`, {
signal: ctx.signal,
});
return res.json();
})
.build();
userStore.user.loading; // true while fetching
userStore.user(); // User | undefined
userStore.user.error; // Error | undefined on rejection
Re-running (when id changes) keeps the previous .value visible while .loading is true.
.action(key, fn)
Registers a named mutation. fn receives props and ctx. Return a Partial patch to merge through middleware, or return nothing when writes use ctx.set only or the action has no state updates.
// Zero-arg — 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 }))
ctx exposes { get(), getInitial(), set(patch) }. Use ctx.set for async/multi-step actions (no return value needed):
.action("load", (_, ctx) => {
ctx.set({ loading: true });
fetchUser(ctx.get().id).then((user) => ctx.set({ user, loading: false }));
})
.middleware(fn)
Intercepts every state mutation before it commits. Receives (patch, ctx, next). Call next(patch) to continue or return early to block. Applies to accessor writes, setState, actions, and bind writes.
store({ count: 0 }).middleware((patch, ctx, next) => {
console.log("before:", ctx.get().count);
next(patch);
});
Multiple middlewares compose in registration order.
.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) |
.build()
Finalizes the builder and returns a live reactive store.
Built-in store methods
State accessors
Every state key is a signal-shaped accessor on the built store — call to read, call with a value to write:
s.count(); // reactive read
s.count(5); // write → goes through middleware
Writes are reactive: any ilha render or derived that called s.count() re-runs. Accessors carry [SIGNAL_ACCESSOR] so they work directly in html templates and bind:* directives.
store.setState(patch)
Atomic multi-key write — one commit, one "change", one re-render. Routes through middleware.
s.setState({ a: 1, b: 2 });
store.reset()
Resets to the initial state captured at .build() time. Routes through middleware; no-op if already at initial state.
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)
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(
(s) => s.count,
(count, prev) => {
/* … */
},
);
unsub();
store.select(selector) — reactive read accessor
Projects a slice into a () => S signal accessor. Hoist outside render functions — each call allocates a fresh computed.
const count = s.select((st) => st.count);
count(); // reactive
Use state accessors directly (s.count()) instead of select when you don't need an ad-hoc projection.
store.bind(selector) — two-way bind:*
Returns a read/write accessor for ilha's bind:* directives. Accepts property-path selectors only (s => s.user.name). Writes go through middleware.
const query = s.bind((st) => st.search.query);
// <input bind:value={query} />
effectScope
Re-exported from alien-signals. Runs a setup function inside a reactive scope and returns stop() to tear down every subscribe effect registered inside it.
import { store, effectScope } from "@ilha/store";
const stop = effectScope(() => {
myStore.subscribe((s) => console.log(s.count));
});
stop();
Usage with ilha islands
State and derived accessors are signal-shaped — use them directly inside .render(), .derived(), and .effect() without .select() 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],
}))
.derived("count", (ctx) => ctx.get().items.length)
.build();
// State and derived read directly — 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.
Use .select() for ad-hoc projections not worth a named .derived(), and .bind() for two-way form fields.
Note: state.todos.select((t) => t[i].done) in island templates is ilha’s nested .select() for bind:* — not store.select() on @ilha/store.
Forms
Three small helpers for building typed, validated forms with any Standard Schema-compatible library.
import {
extractFormData,
validateWithSchema,
validateWithSchemaAsync,
issuesToErrors,
preventDefault,
} from "@ilha/store/form";
extractFormData(source)
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.
const data = extractFormData(event.target as HTMLFormElement);
// → { email: "ada@example.com", role: ["admin", "editor"] }
validateWithSchema(schema, data)
Runs a Standard Schema synchronously. Never throws. Returns { ok: true, data } or { ok: false, issues }. Use validateWithSchemaAsync for async refinements.
issuesToErrors(issues)
Flattens Standard Schema issues into Record<string, string[]> keyed by dot-separated path. Form-level errors (no path) land under "".
issuesToErrors([
{ message: "Required", path: ["email"] },
{ message: "Invalid", path: ["user", "email"] },
]);
// → { email: ["Required"], "user.email": ["Invalid"] }
preventDefault(fn)
Wraps an ilha .on() handler so event.preventDefault() runs first, then your callback receives the same context (event, state, target, …).
ilha.on(
"form@submit",
preventDefault(({ event }) => {
const data = extractFormData(
event.target as HTMLFormElement,
);
// ...
}),
);
Full example — contact form
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"),
message: z.string().min(10, "Too short"),
});
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();
const errors = formStore.errors; // state accessor — reactive
export default ilha
.on(
"form@submit",
preventDefault(({ event }) => formStore.submit(event)),
)
.render(
() => html`
<form>
<input name="name" />
${errors().name
? html`<p role="alert">${errors().name![0]}</p>`
: ""}
<input name="email" type="email" />
${errors().email
? html`<p role="alert">${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, // <T>: () => T | undefined, .loading, .value, .error
DerivedValue, // <T>: { 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";
Related
| Topic | Guide |
|---|---|
Core ilha API | ilha |
| Island-local state | State |
| Multi-page apps | Router |