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

npm install @ilha/store

ilha is an optional peer dependency. Install both so bind:* directives and signal tracking work in templates.

Import paths

Import pathUse it for
@ilha/storestore, store types, subscriptions, select, bind, and effectScope
@ilha/store/formForm 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 localStorage via .on("change", …)
  • Form state — pair with @ilha/store/form helpers 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 derivedfn 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).

EventWhen 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";
TopicGuide
Core ilha APIilha
Island-local stateState
Multi-page appsRouter