Ilha
Overview
ilha is a tiny, framework-free island architecture library. Islands are self-contained interactive components that:
- Render as plain HTML strings on the server (SSR)
- Mount reactively on the client with fine-grained signal-based updates
- Compose via typed props, slots, shared context, and async-derived data
ilha is built on alien-signals for reactivity and accepts any Standard Schema validator (Zod, Valibot, ArkType, …) for prop validation.
Installation
bun add ilha
# or
npm install ilha
Core concepts
An island is built with a chainable builder. Each method returns a new builder — nothing is mutated. The chain is finalised with .render(), which returns a callable Island.
import ilha, { html, mount } from "ilha";
import { z } from "zod";
const counter = ilha
.input(z.object({ count: z.number().default(0) }))
.state("count", ({ count }) => count)
.derived("doubled", ({ state }) => state.count() * 2)
.on("[data-inc]@click", ({ state }) => state.count(state.count() + 1))
.render(
({ state, derived }) => html`
<p>Count: ${state.count}</p>
<p>Doubled: ${derived.doubled.value}</p>
<button data-inc>+</button>
`,
);
// SSR
counter({ count: 5 }); // → "<p>Count: 5</p><p>Doubled: 10</p><button data-inc>+</button>"
// sync here because this island has no async derived values
// Client
mount({ counter });
<div data-ilha="counter" data-props='{"count": 5}'></div>
Builder API
ilha (root builder)
The default export is the root builder. It can be used directly without .input() if no typed props are needed:
import ilha from "ilha";
const greeting = ilha.state("name", "world").render(({ state }) => `<p>Hello, ${state.name()}</p>`);
All builder methods are chainable and immutable — each call returns a new builder instance.
.input()
.input(schema: StandardSchemaV1): Builder
Declares typed, validated props for the island using any Standard Schema compatible validator. Validation runs on every SSR call and every client mount.
import { z } from "zod";
const island = ilha
.input(
z.object({
title: z.string().default("Untitled"),
count: z.number().default(0),
}),
)
.render(({ input }) => `<h1>${input.title}</h1><p>${input.count}</p>`);
island({ title: "Hello", count: 3 }); // → "<h1>Hello</h1><p>3</p>"
island(); // → "<h1>Untitled</h1><p>0</p>"
Throws [ilha] Validation failed if props fail schema validation.
Async schemas are not supported — validation must be synchronous.
input is available in .state() init functions, .derived(), .effect(), .on() handlers, and .render().
.state()
.state(key: string, init: Value | (input) => Value): Builder
Adds a reactive signal to the island. init can be a plain value or a function that receives the validated input and returns the initial value.
ilha
.input(z.object({ count: z.number().default(0) }))
.state("count", ({ count }) => count) // derived from input
.state("step", 1) // plain value
.render(({ state }) => `<p>${state.count()} (step: ${state.step()})</p>`);
State is accessed in .render() and handlers as a signal accessor — call it with no arguments to read, call it with a value to write:
state.count(); // → current value
state.count(5); // → sets to 5, triggers re-render
During SSR, state accessors are read-only plain functions — writes are silently ignored and no effects run.
Multiple .state() calls can be chained:
ilha.state("a", 0).state("b", "hello").state("active", false);
.derived()
.derived(key: string, fn: (ctx) => Value | Promise<Value>): Builder
Derives a value from state or input. The function can be sync or async.
Context
fn({ state, input, signal });
| Property | Type | Description |
|---|---|---|
state |
IslandState |
Reactive state accessors |
input |
TInput |
Validated input props |
signal |
AbortSignal |
Cancelled when dependencies change (async only) |
Sync derived
Value is computed immediately, loading is always false. Re-runs synchronously when any accessed state changes.
ilha
.state("count", 0)
.derived("doubled", ({ state }) => state.count() * 2)
.render(({ state, derived }) => `<p>${state.count()} × 2 = ${derived.doubled.value}</p>`);
During SSR, sync derived resolves immediately with the correct value.
Async derived
Result is wrapped in a { loading, value, error } envelope. Stale requests are aborted automatically via the AbortSignal when dependencies change. The previous value is preserved while re-fetching.
ilha
.state("query", "")
.derived("results", async ({ state, signal }) => {
const res = await fetch(`/api/search?q=${state.query()}`, { signal });
return res.json();
})
.render(({ derived }) => {
const { loading, value, error } = derived.results;
if (loading) return `<p>Loading${value ? " (updating…)" : ""}…</p>`;
if (error) return `<p>Error: ${error.message}</p>`;
return `<ul>${value.map((r: string) => `<li>${r}</li>`).join("")}</ul>`;
});
During SSR, async derived supports two modes:
await island()resolves async derived before producing the final HTMLisland.toString()and implicit string interpolation stay synchronous, so async derived remains in its loading state
This lets you choose between async SSR and a synchronous loading fallback depending on how you render the island.
Derived envelope
Every .derived() key is accessible as:
derived.key.loading; // boolean — always false for sync
derived.key.value; // T | undefined
derived.key.error; // Error | undefined — always undefined for sync
Multiple derived keys
ilha
.state("n", 4)
.derived("square", ({ state }) => state.n() ** 2)
.derived("label", async ({ state }) => fetchLabel(state.n()))
.render(
({ derived }) =>
`<p>${derived.square.value} — ${derived.label.loading ? "…" : derived.label.value}</p>`,
);
.bind()
.bind(selector: string, stateKey: string): Builder
Creates a two-way binding between a form element and a state key. No event handler boilerplate needed.
ilha
.state("email", "")
.state("age", 0)
.state("subscribed", false)
.bind("[data-email]", "email")
.bind("[data-age]", "age")
.bind("[data-sub]", "subscribed")
.render(
({ state }) => html`
<input data-email value="${state.email()}" />
<input type="number" data-age value="${state.age()}" />
<input type="checkbox" data-sub ${state.subscribed() ? "checked" : ""} />
`,
);
Directions
- DOM → state — user interaction updates the signal immediately
- state → DOM — programmatic signal writes sync back to the element's property
Type coercion
The DOM value is automatically coerced to match the type of the current state value. If the state is a number and the input is cleared, the value falls back to 0 rather than NaN.
.state("count", 0)
.bind("[data-count]", "count") // "42" from DOM → 42 in state automatically
Supported elements
| Element | Listens on | Reads / writes |
|---|---|---|
input (text, email, …) |
input |
.value |
input[type=number] |
input |
.valueAsNumber |
input[type=checkbox] |
change |
.checked |
input[type=radio] |
change |
selected .value |
select |
change |
.value |
textarea |
input |
.value |
For radio groups, bind all radios in the group to the same state key, typically via a shared selector like [name=plan]. The state stores the selected radio's value.
SSR behaviour
.bind() is a complete no-op during SSR. It only activates on mount.
Stale element references
Every state change triggers a re-render which replaces el.innerHTML. Any element reference captured before a state change is a detached element. Always re-query from the root el after interactions:
// ✗ stale — captured before re-render
const input = el.querySelector("[data-q]")!;
input.dispatchEvent(new Event("input"));
input.value; // detached element
// ✓ always re-query
el.querySelector<HTMLInputElement>("[data-q]")!.dispatchEvent(new Event("input"));
el.querySelector<HTMLInputElement>("[data-q]")!.value; // live element
.on()
.on(selectorAtEvent: string, handler: (ctx) => void | Promise<void>): Builder
Attaches a delegated DOM event listener. The selector and event are combined in a single string using @ as separator.
Syntax
[css-selector]@[event-type][:modifier]*
.on("[data-btn]@click", handler) // delegated to matching child
.on("@click", handler) // bound to the island root element
.on("[data-btn]@click:once", handler) // fires once then detaches
.on("[data-btn]@submit:passive", handler)
.on("[data-btn]@scroll:passive:capture", handler)
Modifiers
| Modifier | Description |
|---|---|
:once |
Handler fires once, then is automatically removed |
:capture |
Uses capture phase |
:passive |
Marks listener as passive |
Modifiers can be combined in any order after the event type.
Handler context
.on("[data-btn]@click", ({ state, input, el, event }) => {
state.count(state.count() + 1);
event.preventDefault();
});
| Property | Type | Description |
|---|---|---|
state |
IslandState |
Reactive state accessors |
input |
TInput |
Validated input props |
el |
Element |
The island's root DOM element |
event |
Event |
The native DOM event |
Async handlers are supported — errors are caught and logged.
SSR behaviour
.on() is a no-op during SSR.
.effect()
.effect(fn: (ctx) => (() => void) | void): Builder
Runs a reactive side effect on mount. The function is re-run whenever any state it reads changes. Optionally returns a cleanup function.
ilha
.state("count", 0)
.effect(({ state, el }) => {
document.title = `Count: ${state.count()}`;
return () => {
document.title = ""; // cleanup on unmount or before re-run
};
})
.render(({ state }) => `<p>${state.count()}</p>`);
Effect context
| Property | Type | Description |
|---|---|---|
state |
IslandState |
Reactive state accessors |
input |
TInput |
Validated input props |
el |
Element |
The island's root DOM element |
Cleanup
The returned cleanup function is called:
- Before the effect re-runs (when tracked state changes)
- On island unmount
SSR behaviour
.effect() is a no-op during SSR.
.slot()
.slot(name: string, island: Island): Builder
Registers a child island as a named slot. Slots allow composition — a parent island can embed child islands that have their own independent reactive state.
const badge = ilha.state("label", "hello").render(({ state }) => `<span>${state.label()}</span>`);
const card = ilha.slot("badge", badge).render(({ slots }) => `<div>${slots.badge}</div>`);
Rendering slots
Inside .render(), slots is a proxy where each key is a SlotAccessor. A slot can be rendered with or without props:
slots.badge; // renders with child's defaults
slots.badge({ label: "hi" }); // renders with props
In a template literal or html tag, slots.badge calls .toString() automatically:
html`<div>${slots.badge}</div>`;
// or
`<div>${slots.badge}</div>`;
Client behaviour
On the client, the parent renders a placeholder <div data-ilha-slot="name"> in place of each slot. The child island is then mounted onto that placeholder. The placeholder element is preserved across parent re-renders — the child's state and lifecycle are never interrupted by parent updates.
Unmounting a parent cascades to all child slots.
Slot props via HTML
Slots can also receive props declaratively in markup:
<div data-ilha-slot="badge" data-props='{"label": "world"}'></div>
SSR behaviour
During SSR, slots render inline using the child island's synchronous rendering path (toString()). This means async derived values inside child slots stay in their loading state unless the child is rendered directly with await childIsland(...).
.transition()
.transition({ enter?, leave? }): Builder
Registers mount and unmount lifecycle hooks. Both can be async — leave is awaited before teardown begins.
ilha
.transition({
enter: (el) => {
el.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 200 });
},
leave: (el) =>
new Promise<void>((resolve) => {
const anim = el.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 200 });
anim.onfinish = () => resolve();
}),
})
.render(() => `<p>Hello</p>`);
Hooks
| Hook | Signature | Description |
|---|---|---|
enter |
(el: Element) => void | Promise |
Called immediately after initial render on mount |
leave |
(el: Element) => void | Promise |
Called on unmount; teardown waits for promise to resolve |
SSR behaviour
.transition() is a no-op during SSR.
.render()
.render(fn: (ctx) => string): Island
The render function itself must return a plain HTML string.
The resulting island can be rendered in two SSR modes:
- Sync:
island.toString()or implicit string interpolation - Async:
await island(props)when async derived values are present
const island = ilha
.state("x", 0)
.render(({ state, derived, input, slots }) => `<p>${state.x()}</p>`);
Render context
| Property | Type | Description |
|---|---|---|
state |
IslandState |
Reactive signal accessors |
derived |
IslandDerived |
Derived value envelopes |
input |
TInput |
Validated input props |
slots |
SlotsProxy |
Named slot accessors |
The render function must return a plain HTML string. It is called:
- On SSR — once for sync rendering, or after async derived values resolve when using
await island(...) - On client — once on initial mount, then again whenever any accessed signal changes
Island interface
.render() returns an Island, which is a callable function with two additional methods:
interface Island<TInput, TStateMap> {
(props?: Partial<TInput>): string | Promise<string>;
toString(props?: Partial<TInput>): string;
mount(el: Element, props?: Partial<TInput>): () => void;
}
- If all derived values are synchronous,
island(props)returns a string - If any derived value is async,
island(props)returns a Promise that resolves to the final HTML toString()is always synchronous
Calling the island (SSR)
island(); // string if all derived values are sync, otherwise Promise<string>
await island({ count: 5 }); // safe for both
`<div>${island}</div>`; // implicit toString() with defaults
island.toString({ count: 3 }); // always sync
Mounting
const unmount = island.mount(el, { count: 5 });
// later
unmount(); // stops effects, detaches listeners, runs leave transition
Mounting
mount()
import { mount } from "ilha";
mount(registry, options?): MountResult
Auto-discovers all [data-ilha] elements in the DOM and mounts the matching island from the registry.
mount({ counter, form, app });
Options
| Option | Type | Default | Description |
|---|---|---|---|
root |
Element |
document.body |
Scope discovery to a subtree |
hydrate |
boolean |
false |
Preserve existing SSR HTML until first render |
lazy |
boolean |
false |
Mount when element enters viewport (IntersectionObserver) |
mount({ counter }, { root: document.querySelector("#app") });
mount({ counter }, { hydrate: true });
mount({ counter }, { lazy: true });
HTML attributes
<div data-ilha="counter" data-props='{"count": 5}' data-ilha-state='{"count": 42}'></div>
| Attribute | Description |
|---|---|
data-ilha |
Island name — must match a key in the registry |
data-props |
JSON props passed to the island |
data-ilha-state |
Serialised state snapshot — takes priority over data-props on the client |
Return value
const { unmount } = mount({ counter });
unmount(); // tears down all discovered islands
from()
import { from } from "ilha";
from(selector: string | Element, island: Island, props?): (() => void) | null
Mounts a single island onto a specific element. Returns the unmount function, or null if the selector matched nothing.
const unmountA = from("#my-counter", counter, { count: 5 });
const appEl = document.querySelector("#app");
const unmountB = appEl ? from(appEl, app) : null;
Shared state
context()
import { context } from "ilha";
context(key: string, initial: T): ContextSignal<T>
Creates or retrieves a module-level shared signal. The same key always returns the same signal — the initial value from the first registration wins.
const theme = context("theme", "light");
theme(); // → "light"
theme("dark"); // updates all islands subscribed to this signal
Context signals work identically to state signals — calling with no args reads, calling with a value writes. Any island that reads a context signal inside .render() or .effect() will re-render or re-run when the signal changes.
const score = context("score", 0);
const display = ilha.render(() => `<p>${score()}</p>`);
const control = ilha.on("@click", () => score(score() + 1)).render(() => `<button>+1</button>`);
Note: Context signals are global for the lifetime of the page. There is no per-instance scoping or cleanup mechanism.
SSR & hydration
SSR rendering
Islands support both synchronous and asynchronous SSR.
island(); // string for fully sync islands
await island({ count: 3 }); // resolves async derived when needed
island.toString({ count: 3 }); // always sync
During SSR:
- Signal accessors return plain values; writes are ignored
.on(),.effect(),.bind(), and.transition()are no-ops- Sync
.derived()resolves immediately - Async
.derived()can be awaited viaawait island(...) toString()and implicit string interpolation remain synchronous, so async derived values stay in loading state there- Slots render inline through their synchronous slot accessor path
Async SSR example
const profile = ilha
.derived("user", async () => ({ name: "Ada" }))
.render(({ derived }) => {
if (derived.user.loading) return "<p>Loading…</p>";
if (derived.user.error) return `<p>Error: ${derived.user.error.message}</p>`;
return `<p>${derived.user.value!.name}</p>`;
});
await profile(); // "<p>Ada</p>"
profile.toString(); // "<p>Loading…</p>"
`${profile}`; // "<p>Loading…</p>"
Hydration
To restore serialised state on the client without re-running prop validation, embed the state snapshot in the HTML:
<div data-ilha="counter" data-ilha-state='{"count": 42}'>
<p>42</p>
<button data-inc>+</button>
</div>
data-ilha-state takes priority over data-props when both are present. Pass { hydrate: true } to mount() to preserve the SSR HTML until the first client render completes:
mount({ counter }, { hydrate: true });
html template tag
import { html } from "ilha";
A tagged template literal that escapes all interpolations by default. Use it to safely build HTML strings from user-provided or dynamic values.
html`<p>${userInput}</p>`;
// → "<p><script>…</p>"
Interpolation types
| Value | Behaviour |
|---|---|
string, number |
HTML-escaped |
null, undefined |
Omitted (renders nothing) |
raw(str) |
Inserted as-is, no escaping |
| Signal accessor | Called, result is HTML-escaped |
| Slot accessor | Calls .toString(), inserted as raw HTML |
| Other function | Called, result is HTML-escaped |
Leading and trailing blank lines are stripped (dedented) from the result.
Signals in html
Signal accessors can be passed directly without calling them — html detects them and calls them automatically:
html`<p>${state.count}</p>`; // same as html`<p>${state.count()}</p>`
raw()
import { raw } from "ilha";
raw(value: string): RawHtml
Wraps a string to mark it as safe HTML, bypassing html's escaping. Use only with trusted content.
html`<div>${raw("<b>bold</b>")}</div>`;
// → "<div><b>bold</b></div>"
TypeScript
ilha is written in TypeScript and ships full type declarations. The builder tracks the full state and derived map through the chain, so .render() and all handlers are fully typed with no manual annotations needed.
Inferred types
const island = ilha
.input(z.object({ count: z.number().default(0) }))
.state("count", ({ count }) => count) // state.count: SignalAccessor<number>
.derived("doubled", ({ state }) => state.count() * 2) // derived.doubled: DerivedValue<number>
.render(({ state, derived }) => {
state.count(); // → number
derived.doubled.value; // → number | undefined
return `<p>${state.count()}</p>`;
});
Exported types
import type {
Island,
IslandState,
IslandDerived,
DerivedValue,
SignalAccessor,
SlotAccessor,
HandlerContext,
MountOptions,
MountResult,
} from "ilha";
Island<TInput, TStateMap>
The callable island returned by .render().
SignalAccessor<T>
type SignalAccessor<T> = {
(): T;
(value: T): void;
};
DerivedValue<T>
interface DerivedValue<T> {
loading: boolean;
value: T | undefined;
error: Error | undefined;
}
HandlerContext<TInput, TStateMap>
type HandlerContext<TInput, TStateMap> = {
state: IslandState<TStateMap>;
input: TInput;
el: Element;
event: Event;
};
MountOptions
interface MountOptions {
root?: Element;
hydrate?: boolean;
lazy?: boolean;
}
Known limitations
context()signals are global for the page lifetime with no scoping or cleanup mechanism.- Element references go stale after re-render — always re-query from the island root element after any interaction that changes state.