Skip to content
Ilha
Esc
navigateopen⌘Jpreview
On this page

Subscriptions and validation

store.subscribe and store.select, two-way bind, built-in methods, and schema validation with onError.

Read and write shared state reactively, subscribe to changes, and validate commits with a schema.

store(initialState) · store(schema)

Call store() with a plain object, or pass a Standard Schema so writes validate the merged state. Invalid commits are rejected; use .onError() to handle them. Initial state is parsed from the schema (use .default() on fields you want at startup).

import { z } from "zod";

const s = store(
  z.object({
    email: z.email().default("ada@example.com"),
  }),
)
  .onError(({ error, issues }) => {
    /* toast, fieldErrors: error.fieldErrors */
  })
  .build();

Per-keystroke writes skip full-schema validation. State-key accessor writes (s.email("dr")) and bind:* writes commit without running the schema, so drafts like a half-typed email stay in state. This means getState() can hold schema-invalid state while the user is typing. Full validation runs on setState, action patches, and reset. Validate explicitly at trust boundaries (submit handlers) with validateWithSchema(schema, store.getState()) — see Forms.

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 JSX tracks them and bind:* directives accept them directly.

Accessors and store.bind() accept either a value or an updater function:

s.count((previous) => previous + 1);
s.profile.select((profile) => profile.visits)(
  (previous) => previous + 1,
);
s.bind((state) => state.count)((previous) => previous + 1);

The updater receives the latest selected value. If the stored value is a function, return its replacement from an outer updater function.

.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)

"change" handlers are isolated: one throwing handler doesn’t abort the commit or block later handlers — the error is routed to .onError() as source: "listener".

.onError(handler)

Runs when a commit fails schema validation (state stays at the last good snapshot), when an async action rejects, or when a "change" listener throws. Context: { error, source, patch?, path?, issues?, get() } where source is "validate" | "action" | "listener". For validation failures, error is a StoreValidationError (issues, fieldErrors). Without a handler, console.error is used.

.subscribe(listener) / .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(
  (s) => s.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 } (exported from @ilha/store) to compare one level deep instead.

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 but skip full-schema validation so in-progress input still commits.

const query = s.bind((st) => st.search.query);
// <input bind:value={query} />

Built-in store methods

  • store.setState(patch) — atomic multi-key write; one commit, one "change", one re-render. Routes through middleware.
  • store.reset() — resets to the initial state captured at .build() time. Routes through middleware; no-op if already at initial state.
  • store.dispose() — tears the store down: stops all subscribe effects and async-derived effects, aborts in-flight async deriveds, and turns further writes into no-ops. Idempotent. Call it when a store’s lifetime is shorter than the page, otherwise its effects (and any refetching async deriveds) leak.
  • store.getState() / store.getInitialState() — raw TState snapshots (no derived values or actions). The initial state is deep-cloned at .build() time, so mutating the original object later can’t corrupt reset() or getInitialState().
  • 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. Use store.dispose() to tear down the whole store, including its async-derived effects.
Topic Guide
validateWithSchema Forms
Derived values and actions Derived state and actions
Building a store Store overview

Was this page helpful?