---
title: "@ilha/store"
description: Shared reactive store for Ilha islands (alien-signals). Includes /form helpers for type-safe, schema-validated forms.
order: 402
---

import { MultiCopy } from "imprensa/components";

# Store

`@ilha/store` is shared reactive state for ilha apps. It sits outside any island and uses [alien-signals](https://github.com/stackblitz/alien-signals) — the same engine as island [`.state()`](/guide/island/state) — so stores and islands share one reactive graph without bridging.

The `@ilha/store/form` import path adds small helpers on [Standard Schema](https://standardschema.dev) (Zod, Valibot, ArkType, and others). See [Forms](#forms) below.

Island [`.state()`](/guide/island/state) is **local** to one component. Use `@ilha/store` when state must be **shared** across islands or updated from non-island code.

## Install

<MultiCopy
  values={{
    npm: "npm install @ilha/store",
    pnpm: "pnpm add @ilha/store",
    bun: "bun add @ilha/store",
  }}
/>

`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

```ts
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.

```ts
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.

```ts
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:

```ts
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.

```ts
// 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):

```ts
.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.

```ts
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:

```ts
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.

```ts
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.

```ts
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`.

```ts
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.

```ts
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.

```ts
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:

```tsx
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:*`](/guide/island/render#nested-fields-with-select) — not `store.select()` on `@ilha/store`.

## Forms

Three small helpers for building typed, validated forms with any [Standard Schema](https://standardschema.dev)-compatible library.

```ts
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.

```ts
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 `""`.

```ts
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`, …).

```ts
ilha.on(
  "form@submit",
  preventDefault(({ event }) => {
    const data = extractFormData(
      event.target as HTMLFormElement,
    );
    // ...
  }),
);
```

### Full example — contact form

```tsx
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

```ts
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](/guide/libraries/ilha)     |
| Island-local state | [State](/guide/island/state)      |
| Multi-page apps    | [Router](/guide/libraries/router) |
