# Introduction Route: /guide/getting-started/introduction Source: /guide/getting-started/introduction/index.md import { Preview } from "$lib/components/preview"; export const example = `import ilha from "ilha"; import { Button } from "areia"; export default ilha .state("count", 0) .on("button@click", ({ state }) => state.count(state.count() + 1)) .render(({ state }) => (

Count: {state.count()}

)); ` # Introduction ilha is a tiny, isomorphic island framework for building reactive UI components. It lets you render on the server and mount in the browser with fine-grained signal reactivity, without a virtual DOM or compiler overhead. The result is a UI model that stays close to HTML while still giving you state, events, lifecycle hooks, scoped styles, and hydration when you need them. ## What ilha is An **island** is a self-contained component that knows how to render itself to HTML and how to activate itself in the browser. That means the same component can be used for server-side rendering, client-side mounting, or both together in a hydration flow. ilha is built around a fluent builder chain. You declare input, state, derived values, event handlers, effects, transitions, and styles, then finish with [`.render()`](/guide/island/render) to produce a reusable component. ## Why it exists Most UI stacks force you to choose between simplicity and interactivity. ilha keeps both close together: a small API surface, direct DOM updates through signals, and a rendering model that works naturally on the server. This makes ilha a good fit when you want: - Server-rendered markup. - Small, focused interactive islands. - Explicit state and behavior. - No virtual DOM layer. - A lightweight mental model for UI code. ## How it feels to use A typical island reads a lot like a small HTML-aware module: The same component can render to a string on the server and mount into the DOM on the client. That keeps the component logic in one place instead of splitting it across separate templates and client scripts. ## Core ideas ### Isomorphic rendering ilha can produce HTML on the server and activate the same component in the browser. That makes it useful for SSR, hydration, and progressive enhancement. ### Fine-grained reactivity State is handled with signals, so updates are targeted and local. You do not need to rerender an entire application tree just to change one value. ### JSX-first authoring ilha is designed to work naturally with JSX/TSX. The DOM-like syntax is familiar to most developers, and you get full TypeScript support, IDE autocompletion, and standard tooling out of the box. If you prefer a lower-level option or need to avoid a build step, the [`html`` `](/guide/helpers/html) tagged-template API is also available. It uses the same runtime and reactivity model, so you can mix both styles or migrate incrementally. ### Builder-based composition The fluent API lets you layer behavior step by step: - [`.input()`](/guide/island/input) for typed props. - [`.state()`](/guide/island/state) for local reactive state. - [`.derived()`](/guide/island/derived) for computed values. - [`.on()`](/guide/island/on) for events. - [`.effect()`](/guide/island/effect) and [`.onMount()`](/guide/island/onmount) for side effects. - [`.css()`](/guide/island/css) for scoped styles. - [`.render()`](/guide/island/render) to finalize the component. ## When to use ilha ilha is a strong fit when you want: - Interactive UI with small, explicit components. - SSR-friendly rendering without heavy framework machinery. - A simple way to mix server output and client behavior. - Reusable islands rather than one large application shell. It is less about building a giant monolithic app framework and more about composing focused UI pieces that each own their own state and behavior. ## Basic mental model Think of an island as a component with three parts: - **Input**: data from the outside world. - **State**: reactive values owned by the component. - **Render**: HTML output driven by that state and input. Then add behavior on top with events, effects, bindings, and lifecycle hooks. Once you understand that pattern, the rest of the API is mostly a set of focused ways to connect those pieces. --- # .state() Route: /tutorial/counter/state Source: /tutorial/counter/state/index.md import { Preview } from "$lib/components/preview"; export const example = `import ilha from "ilha"; export default ilha .state("count", 0) .render(({ state }) =>

Count: {state.count()}

); ` # State Ilha uses Signals to make your components reactive. Use the `.state()` builder method to define state - any property declared here will automatically trigger UI updates when it changes. Pass the property name and its initial value. All state properties are then available inside `.render()` via the `state` property. ## Similar concepts - React: `useState` - Vue: `reactive()` - Svelte: `$state()` --- # Installation Route: /guide/getting-started/installation Source: /guide/getting-started/installation/index.md import { MultiCopy } from "imprensa/components"; # Installation ilha can be installed with your package manager of choice. For a beta project, start from a template when possible so SSR, mounting, and deployment wiring are already in place. ## Install Install with your package manager: ## Templates If you want to start from a ready-made project instead of wiring everything manually, use one of the official templates. | Template | Command | Sandbox | | ------------------------------------------------------------------- | -------------------------------------------------- | --------------------------------------------------------------------------- | | [Vite](https://github.com/ilhajs/ilha/tree/main/templates/vite) | `npx giget@latest gh:ilhajs/ilha/templates/vite` | [Open](https://stackblitz.com/github/ilhajs/ilha/tree/main/templates/vite) | | [Hono](https://github.com/ilhajs/ilha/tree/main/templates/hono) | `npx giget@latest gh:ilhajs/ilha/templates/hono` | [Open](https://stackblitz.com/github/ilhajs/ilha/tree/main/templates/hono) | | [Nitro](https://github.com/ilhajs/ilha/tree/main/templates/nitro) | `npx giget@latest gh:ilhajs/ilha/templates/nitro` | [Open](https://stackblitz.com/github/ilhajs/ilha/tree/main/templates/nitro) | | [Elysia](https://github.com/ilhajs/ilha/tree/main/templates/elysia) | `npx giget@latest gh:ilhajs/ilha/templates/elysia` | — | Templates are the fastest way to get a working project structure for SSR, routing, and deployment targets without setting everything up from scratch. ## Requirements ilha is designed for modern JavaScript and TypeScript projects. - Use it in apps that can run ESM modules. - Use TypeScript if you want the best editor support. - Use a browser environment for mounting and hydration. - Keep props and hydration snapshots JSON-serializable when rendering on the server. ## Import ```ts twoslash import ilha, { html, raw, css, mount, from, context, } from "ilha"; ``` Use the default export for the builder chain, and named exports for helpers such as `html`, `raw`, and `mount`. ## Minimal example ```tsx twoslash import ilha from "ilha"; const Counter = ilha .state("count", 0) .on("button@click", ({ state }) => state.count(state.count() + 1), ) .render(({ state }) => (

Count: {state.count()}

)); ``` ## Server-side rendering Render the island to an HTML string with `toString()`: ```tsx twoslash import ilha from "ilha"; const Counter = ilha .state("count", 0) .on("button@click", ({ state }) => state.count(state.count() + 1), ) .render(({ state }) => (

Count: {state.count()}

)); // ---cut--- const htmlOutput = Counter.toString(); ``` If your island uses async derived values, you can also await the island itself: ```tsx twoslash import ilha from "ilha"; const Counter = ilha .state("count", 0) .on("button@click", ({ state }) => state.count(state.count() + 1), ) .render(({ state }) => (

Count: {state.count()}

)); // ---cut--- const htmlOutput = await Counter(); ``` ## Client-side mounting Mount the island into a DOM element: ```tsx twoslash import ilha from "ilha"; const Counter = ilha .state("count", 0) .on("button@click", ({ state }) => state.count(state.count() + 1), ) .render(({ state }) => (

Count: {state.count()}

)); // ---cut--- const root = document.getElementById("app"); if (root) { const unmount = Counter.mount(root); } ``` The returned function stops listeners, effects, and other active behavior. Call it when removing the host element manually or when integrating ilha into another router. --- # .on() Route: /tutorial/counter/on Source: /tutorial/counter/on/index.md import { Preview } from "$lib/components/preview"; export const example = `import ilha from "ilha"; import { Button } from "areia"; export default ilha .state("count", 0) .on("[data-action=increase]@click", ({ state }) => { state.count(state.count() + 1); }) .render(({ state }) => ( <>

Count: {state.count()}

)); ` # On Use the `.on()` method to attach event listeners to elements inside your component. It takes a selector and an event name joined by `@`, and a callback that receives the component context — giving you direct access to `state` and more. ```ts .on("[selector]@eventName", callback) ``` The selector works like `document.querySelector` scoped to your component's rendered output. This means you can target any attribute, class, or element — `[data-action=increase]`, `.btn-submit`, `input` — without worrying about conflicts with the rest of the page. Inside the callback, updating state is as simple as calling the state property as a function with a new value. Ilha will re-render only what changed — so clicking the button below updates the count without touching anything else in the DOM. ## Similar concepts - React: `onClick` and other event props - Vue: `v-on` / `@click` - Svelte: `onclick`, `onchange`... --- # Core Concepts Route: /guide/getting-started/core-concepts Source: /guide/getting-started/core-concepts/index.md # Core Concepts ilha is built around a small set of ideas: islands, signals, JSX rendering, and a builder-based API. Once these click, the rest of the library feels straightforward. ## Islands An island is a self-contained UI component that can render itself to HTML on the server and mount itself in the browser. It owns its own state, behavior, and rendering, so each piece of interactivity stays local and explicit. This makes ilha a good fit for server-rendered pages that only need interactivity in specific places. Instead of turning the whole page into one client app, you can activate only the parts that need to be interactive. ## Isomorphic components The same island can be used in two ways: - Rendered to an HTML string for SSR. - Mounted into a DOM element for client-side interactivity. That means you do not have to split a component into separate “server” and “client” versions. One definition can handle both output and activation. ## Signals ilha uses signals for reactive state. A signal is a value you can read and update, and when it changes, the island reacts to that change. A state accessor works as both a getter and setter: ```ts twoslash import { signal } from "ilha"; const count = signal(0); // ---cut--- count(); // read count(5); // write ``` Inside JSX, you can render the signal value directly: ```tsx

{state.count()}

``` This keeps reactive state small and direct. You read what you need, update what you need, and the island updates accordingly. ## Builder chain You create islands with a fluent builder chain. Each method adds one capability, and [`.render()`](/guide/island/render) finalizes the component. A typical island might include: - [`.input()`](/guide/island/input) for typed props. - [`.state()`](/guide/island/state) for local reactive state. - [`.derived()`](/guide/island/derived) for computed or async values. - [`.on()`](/guide/island/on) for event handlers. - [`.effect()`](/guide/island/effect) and [`.onMount()`](/guide/island/onmount) for side effects. - [`.css()`](/guide/island/css) for scoped styles. - [`.render()`](/guide/island/render) to produce the final island. This step-by-step structure is one of the core design ideas in ilha. Instead of putting everything in one large options object, you compose behavior in a readable chain. ## JSX rendering ilha can render islands with JSX. Configure TypeScript with `jsxImportSource: "ilha"`, then return JSX from [`.render()`](/guide/island/render). ```tsx twoslash const userInput = "Ilha is awesome"; // ---cut--- import ilha from "ilha"; const Message = ilha.render(() =>

{userInput}

); ``` JSX output follows safe rendering rules: interpolated values are escaped by default, arrays render without commas, and ilha values such as child islands can be nested directly. If you really need to inject trusted markup, you can opt into that explicitly with [`raw()`](/guide/helpers/raw). ## Derived values Not every value belongs in local state. Sometimes a component needs data that depends on state or input, including async data. That is what [`.derived()`](/guide/island/derived) is for. Each derived entry is a signal accessor — read it with `derived.name()`, the same way you read `state.count()`. You can also write `derived.name(value)` for optimistic UI. For async work, the accessor also exposes `loading`, `value`, and `error`, so loading and error states stay part of the normal rendering model instead of something bolted on from the outside. ## Events and effects ilha separates user interaction from side effects. Use [`.on()`](/guide/island/on) for DOM events such as clicks, input, and change events. Use [`.effect()`](/guide/island/effect) when you want reactive behavior that runs after mount and reruns when its dependencies change. Use [`.onMount()`](/guide/island/onmount) when something should run once after the island is attached to the DOM. This separation helps keep component logic easier to scan: - Events respond to user actions. - Effects respond to reactive changes. - Mount hooks handle lifecycle setup. ## Scoped styles ilha supports component-level styles with [`.css()`](/guide/island/css). Styles are scoped to the island so they stay local and do not leak into nested child islands. This lets you keep structure, behavior, and styling close together when that is useful, without giving up isolation. ## SSR and hydration ilha is designed to work naturally with server rendering and hydration. You can render HTML on the server, send it to the browser, and later activate the island in place. When using hydratable output, ilha can also embed snapshots of state and derived values. That helps restore the component without unnecessary work on first mount. ## Mental model A useful way to think about an island is: - **Input** is data coming in. - **State** is reactive data owned by the component. - **Derived** is data computed from input or state. - **Render** turns all of that into HTML. - **Mount** activates behavior in the browser. If you keep that model in mind, most of the API becomes intuitive. Each builder method just adds one more piece to that flow. --- # .derived() Route: /tutorial/counter/derived Source: /tutorial/counter/derived/index.md import { Preview } from "$lib/components/preview"; export const example = `import ilha from "ilha"; import { Button } from "areia"; export default ilha .state("count", 1) .derived("doubled", ({ state }) => state.count() * 2) .on("[data-action=increase]@click", ({ state }) => { state.count(state.count() + 1); }) .render(({ state, derived }) => ( <>

Count: {state.count()}

Doubled: {derived.doubled()}

)); ` # Derived Now that you can mutate state with `.on()`, derived properties let you compute values from that state automatically — recalculating only when their dependencies change. Use `.derived()` to keep complex logic out of your templates and make components easier to reason about. ```ts .derived("name", ({ state }) => /* computed value */) ``` The callback receives the component context, just like `.on()`. The result is available in `.render()` via `derived.name()` — the same call syntax as state. You can also write `derived.name(value)` for optimistic UI; the next time the derived function runs, it overwrites with the computed result. `.derived()` also accepts an async function, making it suitable for data fetching. Async derived values expose `loading`, `value`, and `error` on the accessor — so you get built-in async state without reaching for SWR or TanStack Query. ## Similar concepts - React: `useMemo` - Vue: `computed()` - Svelte: `$derived()` --- # bind: Route: /tutorial/counter/bind Source: /tutorial/counter/bind/index.md import { Preview } from "$lib/components/preview"; export const example = `import ilha from "ilha"; import { Button, Input, Label } from "areia"; export default ilha .state("count", 0) .derived("doubled", ({ state }) => state.count() * 2) .on("[data-action=increase]@click", ({ state }) => { state.count(state.count() + 1); }) .render(({ state, derived }) => ( <>

Count: {state.count()}

Doubled: {derived.doubled()}

)); ` # Bind Use the `bind:` template syntax in JSX to create a two-way connection between a state signal and a form element. When the state changes, the input updates. When the user types, the state updates — no event listener boilerplate required. ```ts ; ``` The `bind:` prefix goes on the attribute, and the value is a signal accessor — either a local `.state()` accessor or an external `signal()`. Ilha infers the correct value property automatically: `value` for text and number inputs, `checked` for checkboxes. Because binding is declared directly in the template, it lives right next to the element it controls. In the example above, the input and the button both control the same `count` state — either can update it, and both stay in sync. ## Similar concepts - React: controlled inputs with `onChange` + `value` - Vue: `v-model` - Svelte: `bind:value` --- # .effect() Route: /tutorial/counter/effect Source: /tutorial/counter/effect/index.md import { Preview } from "$lib/components/preview"; export const example = `import ilha from "ilha"; import { Button, Input, Label } from "areia"; export default ilha .state("count", 0) .derived("doubled", ({ state }) => state.count() * 2) .on("[data-action=increase]@click", ({ state }) => { state.count(state.count() + 1); }) .effect(({ state }) => { if (state.count() > 3) { state.count(0); } }) .render(({ state, derived }) => ( <>

Count: {state.count()}

Doubled: {derived.doubled()}

)); ` # Effect Use the `.effect()` method to run a side effect whenever reactive state changes. The callback receives the component context — giving you access to `state`, `derived`, and more — and runs automatically after every state update. ```ts .effect(({ state, derived }) => { ... }) ``` Effects are the right place for logic that needs to _react_ to state but doesn't belong in an event handler. This includes enforcing constraints, syncing to external systems, triggering animations, or logging. In the example below, the effect watches `count` and resets it to `0` the moment it exceeds `3`. Because the reset itself is a state update, Ilha re-runs the effect to confirm the new value satisfies the condition — so your constraints are always guaranteed. Effects run synchronously after each state change. If you need to interact with the DOM after a render — for example, to measure an element or focus an input — use `queueMicrotask()` or `requestAnimationFrame()` inside the callback to defer execution until the render is complete. ## Similar concepts - React: `useEffect` - Vue: `watch` / `watchEffect` - Svelte: `$effect()` --- # .onMount() Route: /tutorial/counter/onmount Source: /tutorial/counter/onmount/index.md import { Preview } from "$lib/components/preview"; export const example = `import ilha from "ilha"; import { Button, Input, Label } from "areia"; export default ilha .state("count", 0) .derived("doubled", ({ state }) => state.count() * 2) .on("[data-action=increase]@click", ({ state }) => { state.count(state.count() + 1); }) .onMount(({ state }) => { state.count(2); }) .effect(({ state }) => { if (state.count() > 3) { state.count(0); } }) .render(({ state, derived }) => ( <>

Count: {state.count()}

Doubled: {derived.doubled()}

)); ` # On Mount Use .onMount() to run logic once, immediately after the component renders for the first time. Unlike .effect(), it does not re-run on state changes — making it the right place for one-time setup work. The callback receives the full component context, so you have direct access to state and derived. This makes .onMount() ideal for seeding initial data: fetch a resource, resolve its result, and write it into state — Ilha will re-render automatically once the data arrives. ## Similar concepts - React: `useEffect` with an empty dependency array - Vue: `onMounted()` - Svelte: `onMount()` --- # .input() Route: /guide/island/input Source: /guide/island/input/index.md import { Preview } from "$lib/components/preview"; export const example = `import ilha from "ilha"; export default ilha .input<{ name?: string }>() .render(({ input }) => (

Hello, {input.name ?? "World"}!

)); `; # Input Declares the island's external props and their types. Two forms are supported: a type-only generic for when you just need TypeScript inference, and a [Standard Schema](https://standardschema.dev/)-compatible validator (Zod, Valibot, ArkType, etc.) for runtime validation too. ## Basic usage **Type-only** — TypeScript inference, no runtime validation: **Default props object** — inference plus runtime defaults (shallow merge, no validator): ```tsx twoslash import ilha from "ilha"; const Greeting = ilha .input({ name: "World" }) .render(({ input }) =>

Hello, {input.name}!

); Greeting(); // →

Hello, World!

Greeting({ name: "ilha" }); // →

Hello, ilha!

``` ```tsx twoslash import ilha from "ilha"; ilha .input<{ name: string }>({ name: "World" }) .render(({ input }) =>

{input.name}

); ``` **With a schema** — inference plus runtime validation and coercion: ```tsx twoslash import ilha from "ilha"; import { z } from "zod"; const Greeting = ilha .input(z.object({ name: z.string().default("World") })) // [!code highlight] .render(({ input }) =>

Hello, {input.name}!

); Greeting.toString({ name: "ilha" }); // →

Hello, ilha!

Greeting.toString(); // →

Hello, World!

``` ## Why use `.input()` Without `.input()`, any props passed to an island are untyped and unvalidated. Adding a type or schema gives you: - Full TypeScript inference for `input` inside [`.state()`](/guide/island/state), [`.render()`](/guide/island/render), [`.on()`](/guide/island/on), [`.effect()`](/guide/island/effect), and every other builder method. - Runtime validation and coercion on every call, including during SSR and hydration (schema form only). - Default values handled by the schema itself, so the island works without props (schema form only). ## Choosing a form | | `.input()` | `.input({ … })` / `.input({ … })` | `.input(schema)` | | -------------------- | ------------- | ------------------------------------ | ---------------------- | | TypeScript inference | ✓ | ✓ | ✓ | | Runtime validation | — | — | ✓ | | Default values | — | ✓ (shallow merge) | ✓ (via schema) | | Extra dependency | — | — | ✓ (Zod, Valibot, etc.) | Use `.input()` when callers always pass props. Use `.input({ … })` for lightweight defaults without a schema (same idea as `store({ … })`). Use `.input(schema)` when you need coercion or validation — especially for islands hydrated from serialized server props. ## Using defaults **POJO form** — pass a defaults object to `.input({ … })`. Ilha shallow-merges `{ ...defaults, ...props }` on each call. **Schema form** — defaults live in the schema (Zod `.default()`, etc.). Validation runs on the merged props object. ```tsx twoslash import ilha from "ilha"; import { z } from "zod"; const Card = ilha .input( z.object({ title: z.string(), accent: z.string().default("teal"), }), ) .render(({ input }) => (
{input.title}
)); ``` ## State initialized from input Once you have typed input, you can use it to initialize state: ```tsx twoslash import ilha from "ilha"; import { z } from "zod"; const Counter = ilha .input(z.object({ start: z.number().default(0) })) .state("count", ({ start }) => start) // [!code highlight] .render(({ state }) =>

{state.count()}

); ``` The initializer function receives the resolved input object, so state stays in sync with whatever props were passed in. This works identically with both forms. ## Async schemas Async schemas are not supported. If your validator's `validate()` method returns a `Promise`, ilha will throw at runtime. Keep schemas synchronous. ## Notes - `.input()` must be called before any other builder method if you want the input type to flow through the chain. - Calling `.input()` resets the builder — any previously chained [`.state()`](/guide/island/state) or other methods are not carried over. - If `.input()` is omitted entirely, props are accepted as `Record` with no validation. --- # .state() Route: /guide/island/state Source: /guide/island/state/index.md import { Preview } from "$lib/components/preview"; export const example = `import ilha from "ilha"; import { Button } from "areia"; export default ilha .state("count", 0) .on("button@click", ({ state }) => { state.count(state.count() + 1); }) .render(({ state }) => (

Count: {state.count()}

)); ` # State Declares a reactive signal local to the island. State is the primary way to store values that change over time and drive re-renders. [Interactive Tutorial](/tutorial/counter/state) ## Basic usage ## Reading and writing Each state entry becomes a signal accessor — a function that both reads and writes depending on how it is called: ```ts state.count(); // read → returns current value state.count(5); // write → sets value to 5 ``` When a signal is written, the island re-renders automatically. Only the affected island updates — nothing outside it is touched. ## Initializing from input The initial value can be a static value or a function that receives the resolved input: ```tsx twoslash import ilha from "ilha"; import { z } from "zod"; const Counter = ilha .input(z.object({ start: z.number().default(0) })) .state("count", ({ start }) => start) // [!code highlight] .render(({ state }) =>

{state.count()}

); ``` This is evaluated once at mount time. The initializer is not reactive — it only runs when the island is first created. ## Multiple state entries Chain `.state()` as many times as needed. Each key becomes a typed accessor on the `state` object: ```tsx twoslash import ilha from "ilha"; const Form = ilha .state("name", "") .state("submitted", false) .state("count", 0) .render(({ state }) => (

{state.name()} — {state.count()}

)); ``` ## Inside JSX Signal accessors can be rendered directly in JSX without calling them. ilha detects signal accessors and calls them automatically, and applies HTML escaping: ```tsx twoslash import ilha from "ilha"; const Island = ilha .state("label", "hello") .render(({ state }) =>

{state.label}

); ``` If you call `state.label()` explicitly it works the same way — both forms are equivalent inside JSX. ## Updating state from events State accessors are plain functions, so they work directly as setters inside event handlers: ```tsx twoslash import ilha from "ilha"; const Toggle = ilha .state("open", false) .on("button@click", ({ state }) => state.open(!state.open())) // [!code highlight] .render(({ state }) => (
{state.open() ?

Content

: ""}
)); ``` ## Sharing state across islands State declared with `.state()` is local to one island. If you need to share a value across multiple islands, use [`context()`](/guide/helpers/signals) instead, which creates a named global signal. ## Notes - State keys must be unique within the same builder chain. - The initial value type inferred from the second argument becomes the permanent type of the accessor. Passing a value of a different type later will cause a TypeScript error. - State is not persisted between page loads unless you use [`.hydratable()`](/guide/island/hydratable) with `snapshot: true` on the server side. --- # .derived() Route: /guide/island/derived Source: /guide/island/derived/index.md import { Preview } from "$lib/components/preview"; export const example = `import ilha from "ilha"; import { Input } from "areia"; export default ilha .state("price", 100) .state("qty", 3) .derived("total", ({ state }) => { return state.price() * state.qty(); }) .render(({ state, derived }) => (

Total: {derived.total()}

)); ` # Derived Declares a computed value that depends on state or input. Derived values can be synchronous or async, and they re-run automatically when any reactive dependency changes. [Interactive Tutorial](/tutorial/counter/derived) ## Basic usage Each derived entry is a signal accessor — call it with no arguments to read the resolved value, the same way you read `state.count()`: ```ts derived.total(); // read → returns current value ``` When any signal the derived function reads changes, the function re-runs and the island re-renders. ## Reading and writing Derived accessors can also be written for optimistic UI. A write updates the value immediately without waiting for the derived function to re-run: ```ts derived.total(999); // write → sets value optimistically ``` ```tsx twoslash import ilha from "ilha"; const Cart = ilha .state("price", 100) .state("qty", 3) .derived("total", ({ state }) => state.price() * state.qty()) .on("button@click", ({ derived }) => derived.total(0)) .render(({ derived }) =>

Total: {derived.total()}

); ``` The next time the derived function runs (for example after state changes or an async fetch resolves), it overwrites the optimistic value with the computed result. ## The derived envelope Every derived value also exposes `loading`, `value`, and `error` on the accessor itself. Use these when you need explicit control — especially with async derived values: | Property | Type | Description | | --------- | -------------------- | ------------------------------------- | | `loading` | `boolean` | `true` while the function is running | | `value` | `T \| undefined` | The last successfully resolved value | | `error` | `Error \| undefined` | Set if the function threw or rejected | ```ts derived.total(); // same as derived.total.value when resolved derived.total.value; // envelope read derived.total.loading; // false for sync derived after first run derived.total.error; // undefined when no error ``` For synchronous derived values, `loading` is `false` after the first run and `()` returns the computed value directly. For async derived values, check `loading` and `error` before reading `value` or calling `()`. ## Async derived values Pass an async function to fetch data or run any other asynchronous work. The envelope tracks progress while the promise is pending: ```tsx twoslash import ilha from "ilha"; const UserCard = ilha .state("userId", 1) // [!code highlight:4] .derived("user", async ({ state, signal }) => { const res = await fetch(`/api/users/${state.userId()}`, { signal, }); return res.json(); }) .render(({ derived }) => { if (derived.user.loading) return

Loading…

; if (derived.user.error) return

Error: {derived.user.error.message}

; return

{derived.user().name}

; }); ``` On first render, `loading` is `true` and `derived.user()` is `undefined` until the promise resolves. ## Reactive dependencies The derived function re-runs whenever any signal it reads changes. Dependencies are tracked automatically — you do not need to declare them manually. ```tsx twoslash import ilha from "ilha"; const Search = ilha .state("query", "") .derived("results", async ({ state, signal }) => { const res = await fetch(`/api/search?q=${state.query()}`, { signal, }); return res.json() as Promise; }) .render(({ state, derived }) => ( <> {derived.results.loading ? (

Searching…

) : (
    {derived.results()?.map((r) => (
  • {r}
  • ))}
)} )); ``` ## Abort signal Every async derived function receives an `AbortSignal` that aborts when the function is about to re-run. Pass it to `fetch` or any other cancellable API to avoid stale responses: ```tsx twoslash import ilha from "ilha"; const Island = ilha .state("id", 1) .derived("data", async ({ state, signal }) => { const res = await fetch(`/api/items/${state.id()}`, { signal, }); return res.json(); }) .render(({ derived }) => (

{derived.data()?.name ?? "…"}

)); ``` If the signal was already aborted before your async work completes, the result is discarded silently. ## Keeping stale value during reload When a derived function re-runs, `loading` becomes `true` but `value` retains the previous result until the new one resolves. This lets you avoid layout shifts by showing stale content while refreshing: ```tsx twoslash import ilha from "ilha"; const Island = ilha .state("page", 1) .derived("items", async ({ state, signal }) => { const res = await fetch(`/api/items?page=${state.page()}`, { signal, }); return res.json() as Promise; }) .render(({ derived }) => ( <>
    {derived.items()?.map((i) => (
  • {i}
  • ))}
)); ``` ## SSR behavior During SSR, derived functions are called once. If they are async, the island awaits them before rendering when called as `await island(props)`. When called synchronously via `island.toString()`, async derived values render with `loading: true` immediately. ```ts // Async — waits for all derived values to resolve const html = await MyIsland({ userId: 1 }); // Sync — derived renders in loading state const html = MyIsland.toString({ userId: 1 }); ``` ## Hydration snapshots When using `.hydratable()` with `snapshot: true`, derived values are embedded in the server output and restored on the client. This means the island can render immediately on mount without re-fetching, using the server-resolved value as the initial state. See [`.hydratable()`](/guide/island/hydratable) for full snapshot options. ## Notes - Derived keys must be unique within the same builder chain. - **Rejected or thrown async derived work** sets `derived.key.error` on the envelope — it is **not** routed to [`.onError()`](/guide/island/onerror). Handle failures in the render path via `error` / `loading`. - Async schemas are not supported as derived functions — the function itself can be async, but ilha [`.input()`](/guide/island/input) schemas must remain synchronous. - Multiple derived entries are independent. Each tracks its own dependencies and re-runs on its own schedule. --- # .on() Route: /guide/island/on Source: /guide/island/on/index.md import { Preview } from "$lib/components/preview"; export const example = `import ilha from "ilha"; import { Input, Button } from "areia"; import { toast } from "sonner"; export default ilha .state("email", "") .derived("valid", ({ state }) => { return state.email().includes("@"); }) .on( "form@submit", ({ event, state }) => { event.preventDefault(); state.email(""); toast.success("Subscribed!"); } ) .render(({ state, derived }) => (
)); ` # On Attaches a DOM event listener to the island host or any descendant element. Listeners are set up at mount time and cleaned up automatically on unmount. [Interactive Tutorial](/tutorial/counter/on) ## Basic usage ## Selector syntax The first argument combines a CSS selector and an event name using `@` as a separator: ``` "cssSelector@eventName" ``` Omit the selector to target the island host element itself: ```ts .on("@click", handler) // host click .on("button@click", handler) // any
    )); ``` ## Race-cancellation with `:abortable` When the same listener fires again on the same target, the previous invocation's signal aborts. This is opt-in via the `:abortable` modifier: ```tsx twoslash import ilha from "ilha"; const Search = ilha .state("query", "") .state("results", []) .on( "input@input:abortable", async ({ state, event, signal }) => { const q = (event.target as HTMLInputElement).value; state.query(q); const res = await fetch(`/search?q=${q}`, { signal }); if (signal.aborted) return; state.results(await res.json()); }, ) .render(({ state }) => ( <>
      {state.results().map((r) => (
    • {r}
    • ))}
    )); ``` Race-cancellation is scoped per-target — clicking button A does not cancel an in-flight handler on button B. ## Async handlers and errors Async errors (and sync throws) are caught automatically and routed through the [error sink](/guide/island/onerror): per-island [`.onError()`](/guide/island/onerror), then [`onUncaughtError()`](/guide/island/onerror#global-error-sink-onuncaughterror), then `console.error`. `AbortError` rejections from cancelled work are filtered out and do not reach any of those handlers. ```tsx twoslash import ilha from "ilha"; const Form = ilha .state("loading", false) .on("form@submit", async ({ state, event, signal }) => { event.preventDefault(); state.loading(true); try { await fetch("/api/submit", { method: "POST", signal }); } finally { state.loading(false); } }) .render(({ state }) => (
    )); ``` ## Multiple listeners Chain `.on()` as many times as needed. Each call adds an independent listener: ```tsx twoslash import ilha from "ilha"; const Counter = ilha .state("count", 0) .on("[data-action=increment]@click", ({ state }) => state.count(state.count() + 1), ) .on("[data-action=decrement]@click", ({ state }) => state.count(state.count() - 1), ) .on("[data-action=reset]@click", ({ state }) => state.count(0), ) .render(({ state }) => (

    {state.count()}

    )); ``` ## Implicit batching Multiple synchronous state writes inside a single handler produce one re-render, not one per write: ```ts .on("@click", ({ state }) => { state.a(1); state.b(2); state.c(3); // → one render, not three }) ``` ## Dev mode warnings In development, if a selector matches no elements at mount time, ilha logs a warning. This is not an error — the element may not exist yet if it is rendered conditionally. The warning is suppressed in production. ## Notes - Listeners are attached to the island host and use standard `addEventListener` under the hood — there is no event delegation layer. - Selectors are evaluated with `querySelectorAll` at mount time and after each re-render. If new matching elements appear after mount, they are picked up automatically on the next re-render cycle. - The `once` modifier tracks fired listeners per entry. If the island re-renders before a `once` listener fires, the listener is still considered active and will not be re-attached. --- # .effect() Route: /guide/island/effect Source: /guide/island/effect/index.md import { Preview } from "$lib/components/preview"; export const example = `import ilha from "ilha"; import { Input } from "areia"; import { each } from "quando"; export default ilha .state("changes", [] as string[]) .state("label", "Hello") .effect(({ state }) => { const label = state.label(); if (!label) return; const head = state.changes()[0]; if (head === label) return; state.changes([label, ...state.changes()]); }) .render(({ state }) => (
    {each(state.changes()) .as((change, index) => (

    {change}

    )) .else(

    No changes yet.

    )}
    )); ` # Effect Registers a reactive side effect that runs after the island mounts and re-runs automatically whenever any signal it reads changes. Use it to sync state to the outside world — the DOM, browser APIs, timers, or external systems. [Interactive Tutorial](/tutorial/counter/effect) ## Basic usage Every time `state.title` changes, the effect re-runs and updates `document.title`. ## Cleanup Return a function from the effect to clean up before the next run or on unmount: ```tsx twoslash import ilha from "ilha"; const Island = ilha .state("delay", 1000) .effect(({ state }) => { const id = setInterval(() => { console.log("tick"); }, state.delay()); return () => clearInterval(id); // [!code highlight] }) .render(({ state }) =>

    Interval: {state.delay()}ms

    ); ``` The cleanup runs before the effect re-runs with new values, and once more on unmount. This prevents stale timers, subscriptions, or event listeners from accumulating. ## Cancelling async work with `ctx.signal` Unlike `.on()`, race-cancellation is the **default** behaviour for effects (no modifier needed). When a dependency changes, the previous run's signal aborts automatically. Pass `signal` to async work to bail out of stale invocations: ```tsx twoslash import ilha from "ilha"; const Island = ilha .state("userId", 1) .state("user", null as { name: string } | null) .effect(({ state, signal }) => { fetch(`/api/users/${state.userId()}`, { signal }) .then((res) => { if (signal.aborted) return; return res.json(); }) .then((data) => { if (signal.aborted) return; state.user(data as { name: string }); }) .catch((err) => { if (err && err.name === "AbortError") return; throw err; }); }) .render(({ state }) => (

    {state.user()?.name ?? "Loading…"}

    )); ``` Both the user-supplied cleanup function (if any) and the signal abort fire when the effect re-runs, so you can mix patterns. ## Effect context The effect function receives an `EffectContext`: ```ts { state: IslandState; // reactive state signals derived: IslandDerived; // derived signal accessors (same as render / .on()) input: TInput; // resolved input props host: Element; // island root element signal: AbortSignal; // aborts when the effect re-runs or the island unmounts } ``` Reading `derived.name()` subscribes the effect, like state. Writing `derived.name(value)` for optimistic UI does **not** subscribe — only reads and envelope property access (`.loading`, `.value`, `.error`) are tracked. ## Multiple effects Chain `.effect()` as many times as needed. Each runs independently with its own dependency tracking: ```tsx twoslash import ilha from "ilha"; const Island = ilha .state("title", "Hello") .state("color", "teal") .effect(({ state }) => { document.title = state.title(); }) .effect(({ state }) => { document.body.style.backgroundColor = state.color(); }) .render(({ state }) =>

    {state.title()}

    ); ``` ## Implicit batching Multiple synchronous state writes inside an effect run propagate atomically — dependents see the final state and run once instead of once per write: ```tsx twoslash import ilha from "ilha"; const Island = ilha .state("a", 0) .state("b", 0) .effect(({ state }) => { // These two writes produce a single re-render. state.a(state.a() + 1); state.b(state.b() + 1); console.log(state.a(), state.b()); }) .render(({ state }) => (

    {state.a()} {state.b()}

    )); ``` ## Conditional reads Dependencies are tracked based on which signals are actually read during a run. Signals inside a branch that does not execute are not tracked: ```tsx twoslash import ilha from "ilha"; const Island = ilha .state("enabled", false) .state("value", 0) .effect(({ state }) => { if (!state.enabled()) return; // if false, state.value is never read console.log(state.value()); // only tracked when enabled is true }) .render(({ state }) =>

    {state.value()}

    ); ``` This means the effect only re-runs when `state.value` changes if `state.enabled` was `true` during the last run. ## `.effect()` vs `.onMount()` Both run after mount, but they serve different purposes: | | `.effect()` | `.onMount()` | | ------------------ | ------------------------------ | -------------- | | Re-runs | Yes, when dependencies change | No, runs once | | Tracks signals | Yes | No | | Receives `derived` | Yes | Yes | | Cleanup support | Yes | Yes | | Use for | Reactive sync to external APIs | One-time setup | If you need something to happen only once after mount, use [`.onMount()`](/guide/island/onmount). If you need it to stay in sync with state over time, use `.effect()`. ## Notes - Effects run client-side only. They are not called during SSR. - Effects are registered **after** [`.onMount()`](/guide/island/onmount) (and after any `enter` transition). See [Transition — mount order](/guide/island/transition#mount-order). - Sync throws from an effect (or its cleanup) go to [`.onError()`](/guide/island/onerror) with `source: "effect"`. Unhandled promise rejections from fire-and-forget async inside an effect are **not** caught — use `await` or `.catch()` inside the effect. - The first effect run happens soon after mount; keep effect bodies fast to avoid blocking rendering. - Avoid writing to signals inside an effect that reads those same signals — this creates an infinite loop. --- # .onMount() Route: /guide/island/onmount Source: /guide/island/onmount/index.md import { Preview } from "$lib/components/preview"; export const example = `import ilha from "ilha"; export default ilha .onMount(({ host }) => { const box = host.querySelector("#box"); if (!box) return; box.classList.add("bg-green-200"); }) .render(() => (
    Hello there
    )); ` # On Mount Registers a function that runs once after the island is mounted into the DOM. Use it for one-time setup that needs access to the host element, such as initializing third-party libraries, measuring layout, or setting up manual DOM integrations. [Interactive Tutorial](/tutorial/counter/onmount) ## Basic usage ## Cleanup Return a function to run cleanup on unmount: ```tsx twoslash import ilha from "ilha"; const Island = ilha .onMount(({ host }) => { const observer = new ResizeObserver(() => { console.log("resized", host.clientWidth); }); observer.observe(host); return () => observer.disconnect(); // [!code highlight] }) .render(() =>
    hello
    ); ``` The cleanup function is called when `unmount()` is invoked, just before the island tears down its listeners and effects. ## Mount context The function receives an `OnMountContext`: ```ts { state: IslandState; // reactive state signals derived: IslandDerived; // current derived values input: TInput; // resolved input props host: Element; // island root element hydrated: boolean; // true when mounted over SSR content } ``` The `hydrated` flag tells you whether the island was activated over existing server-rendered HTML or freshly mounted into an empty element. This is useful when you want to skip an animation or initialization step for content that is already visible: ```tsx twoslash import ilha from "ilha"; const Island = ilha .onMount(({ host, hydrated }) => { if (!hydrated) { host.animate([{ opacity: 0 }, { opacity: 1 }], 300); } }) .render(() =>
    content
    ); ``` ## Initializing third-party libraries `.onMount()` is the right place to hand off a DOM element to a library that manages its own rendering: ```tsx twoslash declare global { interface Window { MapLibrary: any; } } // ---cut--- import ilha from "ilha"; const Map = ilha .input<{ lat: number; lng: number }>() .onMount(({ host, input }) => { // [!code highlight:4] const map = new window.MapLibrary(host, { center: [input.lat, input.lng], zoom: 12, }); return () => map.destroy(); }) .render(() =>
    ); ``` ## Multiple onMount hooks Chain `.onMount()` as many times as needed. Each runs independently in the order it was declared: ```tsx twoslash import ilha from "ilha"; const Island = ilha .onMount(({ host }) => { console.log("first", host); }) .onMount(({ state }) => { console.log("second"); }) .render(() =>
    hello
    ); ``` ## Skipping onMount during hydration When using [`.hydratable()`](/guide/island/hydratable) with `snapshot: true`, the `skipOnMount` option tells ilha to skip all `.onMount()` calls when the island is rehydrated from a snapshot. This is useful when your mount logic would duplicate work that was already done on the server: ```tsx twoslash import ilha from "ilha"; const Island = ilha .onMount(({ host }) => { console.log("this is skipped on hydration"); }) .render(() =>
    hello
    ); // On the server: await Island.hydratable( {}, { name: "my-island", snapshot: true, skipOnMount: true, // [!code highlight] }, ); ``` ## `.onMount()` vs `.effect()` | | `.onMount()` | `.effect()` | | ------------------- | -------------------------------- | -------------------------------------------- | | Runs | Once after mount | After mount, then on every dependency change | | Tracks signals | No | Yes | | Receives `derived` | Yes | Yes | | Receives `hydrated` | Yes | No | | Cleanup support | Yes | Yes | | Use for | One-time setup, third-party libs | Reactive sync to external APIs | If you need something to stay in sync with state over time, use [`.effect()`](/guide/island/effect) instead. ## Notes - `.onMount()` runs client-side only and is never called during SSR. - On mount, **`.onMount()` runs after the `enter` transition** (if any) and **before** [`.effect()`](/guide/island/effect) callbacks are registered. See [Transition — mount order](/guide/island/transition#mount-order). - Throws from `.onMount()` (or its returned cleanup) are reported to [`.onError()` / `onUncaughtError()`](/guide/island/onerror) with `source: "mount"`. - Writing to signals inside `.onMount()` is safe and will trigger a re-render. --- # .onError() Route: /guide/island/onerror Source: /guide/island/onerror/index.md import { Preview } from "$lib/components/preview"; export const example = `import ilha from "ilha"; import { Button } from "areia"; import { toast } from "sonner"; export default ilha .state("count", 0) .on("button@click", ({ state }) => { if (state.count() > 1) throw new Error("too many clicks"); state.count(state.count() + 1); }) .onError(({ error }) => { toast.error(error.message); }) .render(({ state }) => (

    Count: {state.count()}

    Click the button more than twice to see the error handler in action.

    )); ` # On Error Registers a **per-island** error handler. The runtime routes uncaught errors through a central sink: **local `.onError()` handlers first** (in declaration order), then the app-wide **[`onUncaughtError()`](#global-error-sink-onuncaughterror)** sink if the island has none, then **`console.error`** so nothing is swallowed silently. Island errors that reach the sink include: - [`.on()`](/guide/island/on) — sync throws and async rejections (except filtered `AbortError`) - [`.effect()`](/guide/island/effect) — sync throws from the effect body or its cleanup - [`.onMount()`](/guide/island/onmount) — throws from the mount callback or its returned cleanup - [`.transition()`](/guide/island/transition) — throws or rejections from `enter` / `leave` **Derived** failures are not reported here — they surface on `derived.key.error`. Malformed hydration snapshots degrade gracefully and are not routed to `.onError()`. ## Basic usage ## Catching async rejections `.onError()` also catches rejections from async `.on()` handlers: ```tsx twoslash import ilha from "ilha"; const Form = ilha .state("loading", false) .on("form@submit", async ({ state, event, signal }) => { event.preventDefault(); state.loading(true); const res = await fetch("/api/submit", { method: "POST", signal, }); if (!res.ok) throw new Error("Submit failed"); state.loading(false); }) // [!code highlight:3] .onError(({ error }) => { alert(error.message); }) .render(({ state }) => (
    )); ``` ## Error context The handler receives an `ErrorContext`: ```ts { error: Error; // always wrapped to Error if a non-Error was thrown source: "on" | "effect" | "mount" | "transition"; state: IslandState; // reactive state signals derived: IslandDerived; // current derived values input: TInput; // resolved input props host: Element; // island root element } ``` Use `source` to branch logging or UX: ```tsx twoslash import ilha from "ilha"; const Island = ilha .on("button@click", () => { throw new Error("click failed"); }) .onError(({ error, source }) => { const label = source === "on" ? "Handler" : source === "effect" ? "Effect" : source === "mount" ? "Mount" : "Transition"; console.error(`${label}:`, error); }) .render(() => ); ``` ## Global error sink — `onUncaughtError()` Import **`onUncaughtError`** from `ilha` (not chained on the builder). Register it once in your client entry — for example logging, toast, or telemetry — for **any island that has no local `.onError()`**: ```ts import ilha, { onUncaughtError } from "ilha"; const off = onUncaughtError((error, source) => { console.error(`[ilha:${source}]`, error); }); // later: off() stops delivery ``` The callback receives `(error, source)` only — not full `ErrorContext` (`state`, `host`, etc.). Use per-island `.onError()` when you need that context. | Behavior | Detail | | ---------------- | --------------------------------------------------------------------------------------------------------------------- | | Precedence | Islands with `.onError()` handle errors locally; the global sink is **not** called for those errors. | | Fallback order | No local handlers → global sink(s) → `console.error` if no global handler is registered. | | Multiple globals | Each `onUncaughtError()` registration runs; a throw inside one global handler is logged and does not stop the others. | | Unsubscribe | The returned function removes that handler from the global set. | ```tsx twoslash import ilha, { onUncaughtError } from "ilha"; onUncaughtError((error, source) => { if (source === "on") console.error("[click]", error); }); // This island has no .onError() — clicks reach the global sink. const Loose = ilha .on("button@click", () => { throw new Error("oops"); }) .render(() => ); ``` ## Multiple error handlers Chain `.onError()` as many times as needed. All handlers run in declaration order. An error thrown inside one `.onError()` handler does not break the others — it is logged to `console.error` and execution continues: ```tsx twoslash import ilha from "ilha"; const Island = ilha .on("button@click", () => { throw new Error("boom"); }) .onError(({ error }) => { console.log("first handler", error.message); }) .onError(({ error }) => { throw new Error("handler itself failed"); }) .onError(({ error }) => { console.log("third handler still runs", error.message); }) .render(() => ); ``` ## AbortError is not an error `AbortError` rejections from `.on()` handlers are **not** routed to `.onError()`. They are the expected outcome of cancellation (via `:abortable` race-cancel or unmount) and would otherwise pollute error tracking: ```tsx twoslash import ilha from "ilha"; const Search = ilha .state("query", "") .on("input@input:abortable", async ({ event, signal }) => { const q = (event.target as HTMLInputElement).value; await fetch(`/search?q=${q}`, { signal }); }) .onError(({ error }) => { // This is NOT called for AbortError rejections. console.error(error); }) .render(() => ); ``` ## Catching effect errors `.onError()` catches **synchronous** throws from `.effect()` runs. The runtime does not await async work spawned inside an effect, so passing `signal` to `fetch` only cancels the request — it does not prevent unhandled rejections. You must `await` the promise (if the effect callback is async) or attach `.catch()` inside the effect itself, and handle `AbortError` there. Do not rely on `.onError()` to catch rejected promises from async work inside `.effect()`: ```tsx twoslash import ilha from "ilha"; const Island = ilha .state("count", 0) .effect(({ state }) => { if (state.count() < 0) { throw new Error("count cannot be negative"); } }) .onError(({ error, source }) => { console.error(`[${source}] ${error.message}`); }) .render(({ state }) =>

    {state.count()}

    ); ``` ## Notes - Fallback order: local `.onError()` → `onUncaughtError()` → `console.error`. - `AbortError` rejections from cancelled `.on()` work are always filtered out (not errors). - Errors thrown inside `.onError()` or global handlers are logged but do not break other handlers in the same tier. - `.onError()` and `onUncaughtError()` run **client-side only** — not during SSR. - Rejected promises from fire-and-forget async work inside `.effect()` are not awaited by the runtime; handle them inside the effect (`.catch()` / `await`), not via `.onError()`. --- # .transition() Route: /guide/island/transition Source: /guide/island/transition/index.md import { Preview } from "$lib/components/preview"; export const example = `import ilha from "ilha"; import { Button } from "areia"; const Panel = ilha .transition({ enter: async (host) => { await host.animate( [ { opacity: 0, transform: "translateY(12px) scale(0.98)", }, { opacity: 1, transform: "translateY(0) scale(1)", }, ], { duration: 300, easing: "ease-out", fill: "forwards" }, ).finished; }, leave: async (host) => { await host.animate( [ { opacity: 1, transform: "translateY(0) scale(1)", }, { opacity: 0, transform: "translateY(-8px) scale(0.98)", }, ], { duration: 240, easing: "ease-in", fill: "forwards", }, ).finished; }, }) .render(() => (

    Animated panel

    Fades and slides in on mount; animates out before unmount.

    )); export default ilha .state("open", true) .on("button@click", ({ state }) => state.open(!state.open())) .render(({ state }) => (
    {state.open() ? : null}

    Toggle to replay enter and leave; leave is awaited before cleanup runs.

    )); ` # Transition Attaches enter and leave animation callbacks to the island. The enter callback runs when the island mounts, and the leave callback runs when it unmounts. Both are async — ilha awaits the leave transition before tearing down the island. ## Basic usage The preview mounts a child island (`Panel`) only while `open` is true. Each time it appears, `enter` runs; when you dismiss it, `leave` runs and ilha waits for that animation to finish before tearing the island down. A minimal fade on a single island looks like this: ```tsx twoslash import ilha from "ilha"; const Island = ilha .transition({ enter: async (host) => { await host.animate( [ { opacity: 0, }, { opacity: 1 }, ], { duration: 300, fill: "forwards", }, ).finished; }, leave: async (host) => { await host.animate( [ { opacity: 1, }, { opacity: 0 }, ], { duration: 300, fill: "forwards", }, ).finished; }, }) .render(() =>
    content
    ); ``` ## Enter transition The `enter` callback receives the host element immediately after mount. It does not block the island from being interactive — event listeners and effects are already active when it runs. ```tsx twoslash import ilha from "ilha"; const Island = ilha .transition({ // [!code highlight:9] enter: (host) => { host.animate( [ { transform: "translateY(8px)", opacity: 0 }, { transform: "none", opacity: 1 }, ], { duration: 200, easing: "ease-out" }, ); }, }) .render(() =>
    content
    ); ``` The enter callback does not need to be async if you do not need to await the animation. ## Leave transition The `leave` callback is awaited before ilha runs cleanup. This means event listeners, effects, and signals remain active for the full duration of the leave animation — state updates and re-renders still work while the island is leaving. ```tsx twoslash import ilha from "ilha"; const Island = ilha .transition({ // [!code highlight:4] leave: async (host) => { await host.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 200, }).finished; }, }) .render(() =>
    content
    ); ``` If `leave` throws or rejects, cleanup still runs — the error is routed to [`.onError()` / `onUncaughtError()`](/guide/island/onerror) with `source: "transition"` but does not prevent unmounting. ## Combining enter and leave Both callbacks are optional. You can define only one if the other is not needed: ```tsx twoslash import ilha from "ilha"; const Drawer = ilha .transition({ enter: async (host) => { await host.animate( [ { transform: "translateX(-100%)" }, { transform: "translateX(0)" }, ], { duration: 250, easing: "ease-out", }, ).finished; }, leave: async (host) => { await host.animate( [ { transform: "translateX(0)" }, { transform: "translateX(-100%)" }, ], { duration: 250, easing: "ease-in", }, ).finished; }, }) .render(() =>
    content
    ); ``` ## Using CSS transitions You are not limited to the Web Animations API. Any async work is valid — including toggling a class and waiting for a CSS transition to finish: ```tsx twoslash import ilha from "ilha"; function cssTransitionEnd(el: Element): Promise { return new Promise((resolve) => { el.addEventListener("transitionend", () => resolve(), { once: true, }); }); } const Island = ilha .transition({ enter: async (host) => { host.classList.add("is-entering"); await cssTransitionEnd(host); host.classList.remove("is-entering"); }, leave: async (host) => { host.classList.add("is-leaving"); await cssTransitionEnd(host); }, }) .render(() =>
    content
    ); ``` ## Mount order After the first render is in the DOM, slots are mounted, and event listeners are attached, ilha runs client setup in this order: 1. **`enter` transition** (if [`.transition()`](/guide/island/transition) is set) — may be sync or async; rejections are reported to [`.onError()` / `onUncaughtError()`](/guide/island/onerror) with `source: "transition"`. 2. **[`.onMount()`](/guide/island/onmount)** callbacks (unless skipped via hydration `skipOnMount`). 3. **[`.effect()`](/guide/island/effect)** and derived watchers — then the render effect keeps the host in sync with signals. **`.effect()` callbacks are not registered until after `enter` and `.onMount()` finish** — nothing in the effect system runs during `enter`. Use `enter` for the animation itself; use `.onMount()` for one-time setup that must run after `enter` completes (when `enter` is async, await your animation inside `enter` before returning). If you need child slots mounted before measuring or animating, the DOM is already rendered and slots are mounted before step 1 — only effects and the ongoing render loop start later. ## Notes - Only one `.transition()` call is supported per builder chain. Calling it more than once replaces the previous transition options. - Transitions are client-side only and are never called during SSR. - The `leave` transition is awaited, so a very long or stalled animation will delay cleanup. Make sure your leave animations have a bounded duration or a timeout. --- # .css() Route: /guide/island/css Source: /guide/island/css/index.md import { Preview } from "$lib/components/preview"; export const example = `import ilha from "ilha"; export default ilha .css\` .card { border-radius: 0.5rem; border: 1px solid #86efac; background: #ecfdf5; padding: 1rem; } .card__title { font-weight: 700; color: #166534; margin: 0 0 0.75rem; } .card__button { background: #0d9488; color: white; border-radius: 0.375rem; border: none; padding: 0.5rem 1rem; cursor: pointer; } .card__button:hover { background: #0f766e; } \` .render(() => (

    Scoped card

    )); ` # CSS Attaches scoped styles to the island. Styles are automatically wrapped in a `@scope` rule bounded to the island host, so they apply only within the island and do not leak into child islands. ## Basic usage The preview runs the same `.css` tagged-template form as in the snippet below; styles stay scoped to the island host. ```tsx twoslash import ilha from "ilha"; const Card = ilha.css` // [!code highlight:9] .card__title { font-weight: 700; } .card__button { background: teal; color: white; } `.render(() => (

    Hello

    )); ``` ## Plain string form `.css()` also accepts a plain string, which is useful when importing styles from an external file: ```tsx import ilha from "ilha"; import styles from "./card.css?raw"; const Card = ilha.css(styles).render(() => (

    )); ``` ## Interpolations When using the tagged template form, interpolations work as normal string concatenation: ```tsx twoslash import ilha from "ilha"; const accent = "coral"; // [!code highlight] const Button = ilha.css` .btn { color: white; border: none; padding: 0.5rem 1rem; border-radius: 0.375rem; } .btn--accent { background: ${accent}; // [!code highlight] } `.render(() => ( )); ``` ## Using the `css` tagged template ilha ships a named `css` export that works as a passthrough tag for editor tooling. It enables LSP syntax highlighting and Prettier formatting for CSS strings without any runtime transformation. Use it to author styles outside the builder chain and pass the result in: ```tsx twoslash import ilha, { css } from "ilha"; const styles = css` .card__title { font-weight: 700; } .card__action { background: teal; color: white; } `; const Card = ilha.css(styles).render(() => (

    Title

    )); ``` > `css` (named export) is a plain passthrough tag for tooling. `.css()` (builder method) is what actually attaches styles to the island. They are intentionally separate. ## How scoping works ilha wraps your styles in a `@scope` rule that constrains them to the island host and punches a hole at any nested `[data-ilha]` element: ```css @scope (:scope) to ([data-ilha]) { .card__title { font-weight: 700; } .card__button { background: teal; color: white; } } ``` This means: - Styles apply to descendants of the island host. - Styles do not leak into child islands nested inside. - Selectors use low specificity and do not win unnecessary cascade wars with utility classes. ## SSR output During SSR, a `

    Hello

    ``` ## Client mount On the client, the style element is injected once as the first child of the host. It is preserved across re-renders — morph never replaces it. During hydration, the SSR-emitted `

    Hello

    ``` ## With `@ilha/router` When using file-system routing, `.hydratable()` is called internally by `renderHydratable()` and `renderResponse()`. You typically do not call it directly — the router handles it: ```ts import { pageRouter, registry } from "ilha:pages/server"; // The router calls .hydratable() internally for the matched island const html = await pageRouter.renderHydratable( request.url, registry, ); ``` On the client, import from `ilha:pages/client` (see [Router — virtual modules](/guide/libraries/router#virtual-modules)). For manual setups without the router, call `.hydratable()` directly in your SSR handler. ## Full SSR + hydration example ```tsx twoslash // server.ts import ilha, { mount } from "ilha"; const Counter = ilha .state("count", 0) .on("button@click", ({ state }) => state.count(state.count() + 1), ) .render(({ state }) => (

    Count: {state.count()}

    )); // Server — render with snapshot const body = await Counter.hydratable( { count: 10 }, { name: "Counter", snapshot: true, skipOnMount: true }, ); // Client — hydrate in place mount({ Counter }); ``` ## Notes - `.hydratable()` is always async — it awaits all [`.derived()`](/guide/island/derived) values before rendering, regardless of whether the snapshot includes them. - Props are JSON-serialized into `data-ilha-props`. Values that are not JSON-serializable (functions, class instances, circular references) will cause a runtime error. Keep props plain and serializable. - The snapshot serializes signal values at the moment `.hydratable()` is called. If state changes after this point on the server, those changes are not reflected in the snapshot. --- # Signals Route: /guide/helpers/signals Source: /guide/helpers/signals/index.md # Signals Reactive signals are the primitive that powers state in ilha. In addition to `.state()` (local to an island), ilha exports four signal helpers for cross-island sharing, performance, and control: | Helper | Purpose | | ----------- | ------------------------------------------------------------ | | `signal()` | Create a free-standing signal for one-off shared state | | `context()` | Create a named global signal accessible from anywhere by key | | `batch()` | Group multiple writes into a single propagation pass | | `untrack()` | Read a signal without subscribing the surrounding scope | --- ## `signal(initial)` Creates a free-standing reactive signal that lives outside any island. Useful for sharing state across multiple islands without prop drilling, or for binding form inputs to module-level state. ### Basic usage ```ts twoslash import { signal } from "ilha"; const count = signal(0); count(); // → 0 (read) count(5); // → sets to 5 (write) ``` Reading the signal inside any reactive scope — `.render()`, `.derived()`, `.effect()` — automatically subscribes that scope, so when the signal changes, dependents re-run as if it were local state. ### Sharing state between islands Because `signal()` returns a plain accessor, you can import it into any island. When one island writes to it, all others that read it re-render automatically: ```tsx twoslash import ilha, { signal } from "ilha"; const cartCount = signal(0); const CartButton = ilha .on("button@click", () => cartCount(cartCount() + 1)) // [!code highlight] .render(() => ); const CartBadge = ilha // [!code highlight] .render(() => {cartCount()}); ``` Both islands share the same `cartCount` signal. Clicking the button in `CartButton` updates the badge in `CartBadge` without any wiring between them. ### Using signals in `bind:` bindings Pass a signal directly into a `bind:` attribute to sync a form element with module-level state: ```tsx twoslash import ilha, { signal } from "ilha"; const query = signal(""); const SearchInput = ilha.render( () => , // [!code highlight] ); const SearchResults = ilha.render(() => (

    Results for: {query()}

    )); ``` When the user types, `query` updates and `SearchResults` re-renders automatically — no wiring between islands needed. --- ## `context(key, initial)` Creates a **named global signal** — a reactive signal shared across all islands. Identical keys always return the same signal instance, which makes it useful for app-wide singletons (theme, locale, current user) where you want registry semantics. ```ts twoslash import { context } from "ilha"; const theme = context("app.theme", "light"); theme(); // → "light" theme("dark"); // → sets to "dark" ``` ### `signal()` vs `context()` Both return the same accessor shape and can be used with `bind:` template syntax. Reach for `signal()` when you hold the reference yourself and import it where needed. Reach for `context()` when you want a name-keyed registry so the same signal can be looked up from anywhere by string key — for example, when the consumer lives in a different package or module from where the signal is defined. ### Sharing state between islands Any island that calls `context()` with the same key gets the same signal. When one island writes to it, all others that read it re-render automatically: ```tsx twoslash import ilha, { context } from "ilha"; const cartCount = context("cart.count", 0); const CartButton = ilha .on("button@click", () => cartCount(cartCount() + 1)) // [!code highlight] .render(() => ); const CartBadge = ilha // [!code highlight] .render(() => {cartCount()}); ``` ### Using context in `bind:` bindings Pass a context signal directly into a `bind:` attribute to sync a form element across islands: ```tsx twoslash import ilha, { context } from "ilha"; const query = context("search.query", ""); const SearchInput = ilha.render( () => , // [!code highlight] ); const SearchResults = ilha.render(() => (

    Results for: {query()}

    )); ``` ### Initializing with a type The second argument sets the initial value and infers the signal type. The type is fixed at first call — subsequent calls with the same key return the existing signal regardless of what initial value is passed: ```tsx twoslash import { context } from "ilha"; const count = context("ui.count", 0); // creates signal const same = context("ui.count", 999); // returns same signal, ignores 999 ``` This means context initialization is effectively first-write-wins. Define context signals in a shared module to ensure consistent initialization across your app: ```ts // contexts.ts import { context } from "ilha"; export const theme = context("app.theme", "light"); export const userId = context( "app.userId", null as string | null, ); export const sidebar = context("ui.sidebar", true); ``` ### Reading context inside effects and derived Context signals are reactive — reading them inside [`.effect()`](/guide/island/effect) or [`.derived()`](/guide/island/derived) creates a dependency just like reading local state: ```tsx twoslash import ilha, { context } from "ilha"; const theme = context("app.theme", "light"); const Island = ilha .effect(() => { document.documentElement.dataset["theme"] = theme(); }) .render(() =>
    content
    ); ``` Whenever `theme` is updated anywhere in the app, this effect re-runs. ### SSR behavior `context()` is safe to call during SSR. The registry is module-level, so signals persist for the lifetime of the process. In a server environment where requests share the same module instance, be careful not to store user-specific state in context signals — use [`.input()`](/guide/island/input) and [`.state()`](/guide/island/state) for per-request data instead. --- ## `batch(fn)` Runs `fn` as an atomic batch — multiple signal writes inside the callback produce a single propagation pass, so dependents (effects, deriveds, island re-renders) see the final state and run once instead of once per write. Returns whatever `fn` returns. ### Before and after Without batch, each write triggers its own propagation pass: ```ts twoslash import { signal } from "ilha"; const a = signal(0); const b = signal(0); a(1); // → effects re-run b(2); // → effects re-run again ``` With batch, both writes flush together: ```ts twoslash import { signal, batch } from "ilha"; const a = signal(0); const b = signal(0); batch(() => { a(10); b(20); }); // → effects re-run once ``` ### Implicit batching `.on()` handlers and `.effect()` runs are batched implicitly, so you only need `batch()` when triggering multiple writes from outside an island — for example from a top-level event listener, a `setTimeout` callback, or a WebSocket message handler. ### Nesting Nested `batch()` calls are safe and only flush when the outermost batch ends: ```ts twoslash import { signal, batch } from "ilha"; const count = signal(0); batch(() => { batch(() => { count(1); }); // still inside outer batch — no flush yet count(2); }); // outermost batch ends — single flush ``` --- ## `untrack(fn)` Runs `fn` with reactive tracking suspended. Reading signals inside `fn` returns their current value without subscribing the surrounding scope. Use this in effects or deriveds when you want to peek at state without causing a re-run on its changes. ### React to A, peek at B The canonical pattern: an effect should re-run when `tracked` changes, but read `peeked` only as a one-off value: ```tsx twoslash import ilha, { signal, untrack } from "ilha"; const tracked = signal(0); const peeked = signal("hello"); const Island = ilha .effect(() => { // Re-runs when `tracked` changes, but NOT when `peeked` changes. console.log( tracked(), untrack(() => peeked()), ); }) .render(() =>

    x

    ); ``` `untrack()` returns whatever `fn` returns, so it also works for peeking at derived values or any other reactive read: ```ts twoslash import { signal, untrack } from "ilha"; const s = signal(42); const value = untrack(() => s()); // → 42, no subscription created ``` --- ## Notes - `signal()` vs `context()` — both return the same accessor shape and can be used with `bind:` template syntax. Use `signal()` for one-off shared state where you hold the reference; use `context()` when you want a name-keyed registry. - Keys are global strings. Use namespaced keys like `"app.theme"` or `"cart.count"` to avoid accidental collisions across different parts of your app. - There is no way to delete or reset a context signal once created short of reloading the module. - Context signals are not included in [`.hydratable()`](/guide/island/hydratable) snapshots. If you need server-rendered context values on the client, pass them as island props via [`.input()`](/guide/island/input) and initialize the context signal inside [`.onMount()`](/guide/island/onmount). --- # mount() Route: /guide/helpers/mount Source: /guide/helpers/mount/index.md # Mount Auto-discovers all `[data-ilha]` elements in the DOM and mounts the matching island from a registry. This is the recommended way to activate islands on a page, especially when using SSR and hydration. ## Basic usage ```ts import { mount } from "ilha"; import { Counter, Card } from "./islands"; mount({ Counter, Card }); ``` Each key in the registry maps to a `data-ilha` attribute value in the HTML: ```html
    ``` ## Options ```ts import { mount } from "ilha"; import { Counter } from "./islands"; const { unmount } = mount( { counter: Counter }, { root: document.getElementById("app"), // default: document.body lazy: true, // mount on visibility }, ); ``` | Option | Type | Default | Description | | ------ | --------- | --------------- | ------------------------------------------------- | | `root` | `Element` | `document.body` | Scope discovery to a subtree | | `lazy` | `boolean` | `false` | Use `IntersectionObserver` to mount on visibility | ## Unmounting `mount()` returns an object with an `unmount` function that tears down all discovered islands at once: ```ts import { mount } from "ilha"; import { Counter } from "./islands"; const { unmount } = mount({ Counter }); // Later — stops all effects, removes all listeners unmount(); ``` ## Lazy mounting When `lazy: true` is set, islands are not mounted immediately. Instead, each host element is observed with an `IntersectionObserver` and mounted only when it enters the viewport. This keeps the initial page load lean when islands are below the fold. ```ts import { mount } from "ilha"; import { HeavyChart } from "./islands"; mount({ HeavyChart }, { lazy: true }); ``` Once an island becomes visible it mounts normally and is no longer observed. ## Passing props Props can be embedded directly in the HTML using `data-ilha-props`. `mount()` reads and parses this attribute automatically — no need to pass props through JavaScript: ```html
    ``` ```ts import { mount } from "ilha"; import { Counter } from "./islands"; // No props needed here — they are read from data-ilha-props mount({ Counter }); ``` ## Hydration with state snapshots When using `.hydratable()` on the server, the rendered HTML includes a `data-ilha-state` attribute with a snapshot of signal values. `mount()` reads this automatically and restores state without re-fetching or re-computing: ```html
    ``` ```ts import { mount } from "ilha"; import { Counter } from "./islands"; // Reads data-ilha-state and restores signals from snapshot mount({ Counter }); ``` See [`.hydratable()`](/guide/island/hydratable) for how to generate this output on the server. ## Scoping to a subtree Pass a `root` element to limit discovery to a specific part of the page. This is useful when islands are injected dynamically into a container: ```ts import { mount } from "ilha"; import { Widget } from "./islands"; const container = document.getElementById("dynamic-content")!; const { unmount } = mount({ Widget }, { root: container }); ``` ## Notes - If a `data-ilha` value has no matching key in the registry, that element is silently skipped. - In dev mode, double-mounting the same element logs a warning and returns a no-op for that element. - `mount()` is safe to call before the DOM is fully loaded if you wrap it in a `DOMContentLoaded` listener or place the script at the end of ``. --- # html`` Route: /guide/helpers/html Source: /guide/helpers/html/index.md # HTML An XSS-safe tagged template for building HTML strings. This is ilha's low-level templating API: you can use it directly, but JSX is the preferred authoring style for most apps. Because `html`` ` is plain TypeScript/JavaScript, it does not require JSX syntax, a JSX runtime import, or a build transform. That makes it a great fit for no-build apps, small scripts, server-only rendering, or any place where you want ilha's escaping and composition rules without JSX tooling. Interpolated values are HTML-escaped by default, making the safe path the default and explicit opt-in required for raw markup. ## Basic usage ```ts twoslash import { html } from "ilha"; const name = ""; html`

    ${name}

    `; // →

    <script>alert(1)</script>

    ``` ## Interpolation rules | Value type | Behavior | | -------------------- | ------------------------------------------ | | `string` / `number` | HTML-escaped | | `null` / `undefined` | Omitted — renders as empty string | | `raw(str)` | Inserted as-is, no escaping | | `html\`…\`` | Inserted as-is, already safe | | Signal accessor | Called automatically, value is escaped | | Array | Each item processed recursively, no commas | ## Escaping All string and number interpolations are escaped automatically: ```ts twoslash import { html } from "ilha"; const userInput = ``; const count = 42; html`

    ${userInput}

    `; // →

    <img src=x…>

    html`

    ${count}

    `; // →

    42

    ``` The characters `&`, `<`, `>`, `"`, and `'` are all escaped. ## Skipping null and undefined `null` and `undefined` are silently omitted, making conditional rendering clean: ```ts twoslash import { html } from "ilha"; const error = null; html`
    ${error}
    `; // →
    ``` ## Trusted markup with `raw()` When you need to inject pre-sanitized or server-controlled markup, use [`raw()`](/guide/helpers/raw) to opt out of escaping: ```ts twoslash import { html, raw } from "ilha"; const icon = ``; html``; // → ``` Only use `raw()` with markup you fully control. Never pass user input to `raw()`. ## Nesting html`` results Results of html`` are already safe and pass through unescaped when interpolated into a parent template. This is the foundation of composable templates: ```ts twoslash import { html } from "ilha"; const badge = html`New`; html`
    ${badge}

    Content

    `; // →
    New

    Content

    ``` ## Signal accessors Signal accessors can be interpolated without calling them. ilha detects signal accessors and calls them automatically, then escapes the result: ```ts twoslash import ilha, { html } from "ilha"; const Island = ilha .state("label", "hello") // [!code highlight] .render(({ state }) => html`

    ${state.label}

    `); ``` Both forms are equivalent. The no-call shorthand is purely a convenience. ## List rendering Arrays are processed recursively with no comma joining. The canonical list pattern is: ```ts twoslash import { html } from "ilha"; const fruits = ["apple", "banana", "cherry"]; html`
      ${fruits.map((fruit) => html`
    • ${fruit}
    • `)}
    `; // →
    • apple
    • banana
    • cherry
    ``` Each html`` result in the array passes through unescaped. Mixed arrays of strings and html`` results also work — each item is processed by its own rules. ## Whitespace and indentation `html\`\`` automatically strips leading and trailing blank lines and dedents the template based on the minimum indentation found. This keeps rendered output clean regardless of how the template is indented in source: ```ts twoslash import { html } from "ilha"; const result = html`

    Hello

    `; // →
    \n

    Hello

    \n
    ``` ## Return type html`` returns a `RawHtml` object, not a plain string. This lets ilha distinguish between trusted and untrusted content when the result is interpolated into another template. To get the plain string value, access `.value` or let ilha unwrap it at a render boundary: ```ts twoslash import { html } from "ilha"; const result = html`

    hello

    `; result.value; // → "

    hello

    " ``` In practice you rarely need to access `.value` directly — ilha handles unwrapping automatically at render time. ## Notes - html`` is purely a runtime helper with no compiler step. It works in any JavaScript environment including Node, Bun, Deno, and the browser. - Do not use html`` for CSS or attribute values where HTML escaping is not appropriate. Use the [css``](/guide/helpers/css) tag for stylesheets and plain template literals for everything else. --- # raw() Route: /guide/helpers/raw Source: /guide/helpers/raw/index.md # Raw Marks a string as trusted HTML, bypassing escaping when rendered in JSX or interpolated inside [`html`](/guide/helpers/html). Use it when you need to inject markup you fully control — icons, pre-rendered fragments, or server-sanitized content. ## Basic usage ```tsx twoslash import { raw } from "ilha";
    {raw("hello")}
    ; // →
    hello
    ``` Without `raw()`, the same string would be escaped: ```tsx twoslash
    {"hello"}
    // →
    <em>hello</em>
    ``` ## When to use it `raw()` is appropriate when the markup comes from a source you fully control: ```tsx twoslash import ilha, { raw } from "ilha"; // SVG icons defined in your codebase const chevron = ` `; const Dropdown = ilha // [!code highlight] .render(() => ); ``` ```tsx twoslash import { raw } from "ilha"; // Pre-rendered HTML from a trusted server-side renderer const renderedMarkdown = `

    Title

    Body text.

    `;
    {raw(renderedMarkdown)}
    ; ``` ## When not to use it Never pass user input to `raw()`. It disables all escaping, so any unescaped string becomes a potential XSS vector: ```tsx twoslash import { raw } from "ilha"; // ❌ Never do this const userComment = ``;

    {raw(userComment)}

    ; // ✅ Do this instead — JSX escapes it automatically

    {userComment}

    ; ``` ## Composing with JSX JSX results are already treated as safe and pass through unescaped without needing `raw()`. Reserve `raw()` for plain strings that contain trusted markup: ```tsx twoslash import { raw } from "ilha"; // JSX result — no raw() needed const badge = New;
    {badge}
    ; // Plain string with markup — raw() required const iconStr = ``;
    {raw(iconStr)}
    ; ``` ## Return type `raw()` returns a `RawHtml` object. This means raw values compose freely with JSX and arrays: ```tsx twoslash import { raw } from "ilha"; const icons = ["", ""];
      {icons.map((icon) => (
    • {raw(icon)}
    • ))}
    ; ``` ## Notes - `raw()` only has an effect when rendered by ilha JSX or [`html`](/guide/helpers/html). Elsewhere it simply wraps the string in a `RawHtml` object with no other transformation. - There is no runtime sanitization inside `raw()`. If you need to accept user-generated HTML, sanitize it with a dedicated library such as [DOMPurify](https://github.com/cure53/DOMPurify) before passing it to `raw()`. --- # css`` Route: /guide/helpers/css Source: /guide/helpers/css/index.md # CSS A passthrough tagged template for CSS strings. It has no runtime effect — its sole purpose is to tell editors and language tools that the content is CSS, enabling syntax highlighting, autocompletion, and formatting. ## Basic usage ```ts twoslash import { css } from "ilha"; const styles = css` button { background: teal; color: white; } .label { font-weight: 700; } `; ``` The result is a plain string, identical to what you would get from an untagged template literal. ## Using with the builder Pass the result to the [`.css()`](/guide/island/css) builder method to attach it to an island: ```tsx twoslash import ilha, { css } from "ilha"; const styles = css` .title { font-size: 1.25rem; font-weight: 700; } button { background: teal; color: white; border: none; } `; const Card = ilha // [!code highlight] .css(styles) .render(() => (

    Hello

    )); ``` ## Interpolations Interpolations work as normal string concatenation — values are inserted as-is with no transformation: ```ts twoslash import { css } from "ilha"; const accent = "coral"; const radius = 4; const styles = css` button { background: ${accent}; border-radius: ${radius}px; } `; ``` ## Difference from [`.css()`](/guide/island/css) `css\`\``and`.css()` are intentionally separate: | | css`` | .css() | | -------------- | ----------------------------- | ------------------------------------ | | What it is | Named export, tagged template | Builder chain method | | Runtime effect | None — returns a plain string | Attaches scoped styles to the island | | Purpose | Editor tooling support | Actual style attachment | A common pattern is to use both together: author styles with css`` for tooling support, then pass the result to [`.css()`](/guide/island/css) to attach them: ```tsx twoslash import ilha, { css } from "ilha"; const styles = css` p { color: teal; } `; // ← tooling sees CSS here ilha.css(styles).render(() =>

    hello

    ); // ← styles are attached here ``` ## Organizing styles For larger islands, keeping styles in a separate variable improves readability and keeps the builder chain focused on structure and behavior: ```tsx twoslash import ilha, { css } from "ilha"; const styles = css` .card { border: 1px solid #e2e8f0; border-radius: 8px; padding: 1rem; } .card-title { font-size: 1.125rem; font-weight: 600; margin: 0 0 0.5rem; } .card-body { color: #4a5568; } button { margin-top: 1rem; padding: 0.5rem 1rem; background: teal; color: white; } `; const Card = ilha .state("expanded", false) .on("button@click", ({ state }) => state.expanded(!state.expanded()), ) .css(styles) .render(({ state }) => (

    Title

    {state.expanded() ?

    Content

    : ""}
    )); ``` ## Notes - css`` requires editor tooling to provide any benefit. The [vscode-styled-components](https://marketplace.visualstudio.com/items?itemName=styled-components.vscode-styled-components) extension and Prettier's `prettier-plugin-styled-components` recognize the `css` tag and apply CSS formatting automatically. - The tag works with any string content — there is no validation or parsing at runtime. Syntax errors in your CSS will not be caught by ilha itself, only by your editor or browser. --- # ilha Route: /guide/libraries/ilha Source: /guide/libraries/ilha/index.md # ilha Maps public `ilha` exports to the guide pages that explain them in depth. For narrative tutorials, use [Introduction](/guide/getting-started/introduction) and the [island API](/guide/island/input) sections first. ## Builder chain Start every island with the default export: ```ts import ilha from "ilha"; ``` | API | Purpose | Learn more | | ---------------------------------------- | -------------------------------------------- | -------------------------------------- | | `ilha.input()` / `ilha.input(schema)` | Define typed or validated props. | [Input](/guide/island/input) | | `.state(key, initial?)` | Add local island state. | [State](/guide/island/state) | | `.derived(key, fn)` | Add computed or async derived state. | [Derived](/guide/island/derived) | | `.on(selector, handler)` | Add delegated event handlers. | [On](/guide/island/on) | | `.effect(fn)` | Run reactive side effects. | [Effect](/guide/island/effect) | | `.onMount(fn)` | Run mount-only client effects. | [On Mount](/guide/island/onmount) | | `.onError(fn)` | Per-island error handler. | [On Error](/guide/island/onerror) | | `.transition(options)` | Enter/leave animations on mount and unmount. | [Transition](/guide/island/transition) | | `.css(styles)` | Attach scoped styles. | [CSS](/guide/island/css) | | `.render(fn)` | Produce an island from JSX/HTML. | [Render](/guide/island/render) | | `.hydratable(options?)` | Mark an island for SSR hydration. | [Hydratable](/guide/island/hydratable) | ## Helpers from `ilha` ```ts import { mount, html, raw, css, signal, context, batch, untrack, from, onUncaughtError, } from "ilha"; ``` | Export | Purpose | Learn more | | --------------------------- | --------------------------------------------------- | ------------------------------------------------------------------- | | `onUncaughtError(fn)` | App-wide error sink when islands lack `.onError()`. | [On Error](/guide/island/onerror#global-error-sink-onuncaughterror) | | `mount(registry, options?)` | Mount islands in the browser. | [Mount](/guide/helpers/mount) | | `html\`...\`` | Create escaped HTML template output. | [HTML](/guide/helpers/html) | | `raw(value)` | Mark trusted HTML as unescaped. | [Raw](/guide/helpers/raw) | | `css\`...\`` | Create CSS template output. | [CSS helper](/guide/helpers/css) | | `signal(initial)` | Create a shared reactive accessor. | [Signals](/guide/helpers/signals) | | `context(key, initial)` | Create a keyed shared signal. | [Signals](/guide/helpers/signals) | | `batch(fn)` | Batch multiple writes into one update. | [Signals](/guide/helpers/signals) | | `untrack(fn)` | Read signals without tracking dependencies. | [Signals](/guide/helpers/signals) | | `from(source)` | Adapt external signal-like values for Ilha. | [Signals](/guide/helpers/signals) | ## JSX runtime Use the automatic runtime with a pragma or `tsconfig.json`: ```tsx /** @jsxImportSource ilha */ ``` ```json { "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "ilha" } } ``` Build tools will resolve `ilha/jsx-runtime` in production and `ilha/jsx-dev-runtime` in development. ## Common public types ```ts import type { Island, KeyedIsland, HydratableOptions, IslandState, IslandDerived, HandlerContext, EffectContext, OnMountContext, ErrorContext, ErrorSource, } from "ilha"; ``` | Type | Use it for | | ------------------- | ---------------------------------------------------------------------------- | | `Island` | A renderable/mountable island value. | | `KeyedIsland` | A registry entry with a stable hydration key. | | `HydratableOptions` | Options accepted by `.hydratable()`. | | `IslandState` | State accessors passed to render/handlers/effects. | | `IslandDerived` | Derived accessors passed to render/handlers/effects. | | `HandlerContext` | Event handler context. | | `EffectContext` | Reactive effect context. | | `OnMountContext` | Mount hook context. | | `ErrorContext` | Error handler context. | | `ErrorSource` | Where an error originated: `"on"`, `"effect"`, `"mount"`, or `"transition"`. | ## Related packages | Package | Guide | | -------------- | --------------------------------- | | `@ilha/router` | [Router](/guide/libraries/router) | | `@ilha/store` | [Store](/guide/libraries/store) | --- # @ilha/router Route: /guide/libraries/router Source: /guide/libraries/router/index.md import { MultiCopy } from "imprensa/components"; # Router `@ilha/router` is an isomorphic router for ilha apps: synchronous HTML on the server, signal-driven SPA navigation in the browser. It is a separate package from `ilha`, so you only add it when you need multi-page routing. Every route is an ilha island. The router matches URLs, runs loaders, and mounts the active page. Route context (`routePath`, `routeParams`, and related signals) uses [`context()`](/guide/helpers/signals), so any island can read the current route without extra wiring. ## Install ## Import paths | Import path | Use it for | | ----------------------- | --------------------------------------------------------------------------------- | | `@ilha/router` | Runtime router, loaders, navigation helpers, route context, and built-in islands. | | `@ilha/router/vite` | Vite file-system routing plugin. | | `@ilha/router/rspack` | Rspack file-system routing plugin. | | `@ilha/router/rolldown` | Rolldown file-system routing plugin. | ## Quick start ### Client-side SPA ```ts import { router } from "@ilha/router"; import { HomePage, AboutPage, UserPage, NotFound, } from "./pages"; router() .route("/", HomePage) .route("/about", AboutPage) .route("/user/:id", UserPage) .route("/**", NotFound) .mount("#app"); ``` ### Server-side rendering ```ts import { router } from "@ilha/router"; import { HomePage, AboutPage, NotFound } from "./pages"; const html = router() .route("/", HomePage) .route("/about", AboutPage) .route("/**", NotFound) .render(request.url); return new Response( `${html}`, { headers: { "content-type": "text/html" }, }, ); ``` ### SSR + hydration (recommended) ```ts // server entry — SSR / Nitro import { pageRouter, registry } from "ilha:pages/server"; import "ilha:loaders"; // wires server-only loaders onto pageRouter export default defineEventHandler(async (event) => { const html = await pageRouter.renderHydratable( event.node.req.url ?? "/", registry, ); return new Response( `${html}`, { headers: { "content-type": "text/html" }, }, ); }); ``` ```ts // client entry import { pageRouter, registry } from "ilha:pages/client"; pageRouter.hydrate(registry); ``` ## The `router()` builder `router()` creates a fresh router instance and resets the route registry. Always call it fresh — never share instances across server requests. ### `.route(pattern, island, loader?)` Registers a route. Uses [rou3](https://github.com/h3js/rou3) for matching — the same engine as Nitro. First match wins. | Pattern | Matches | `routeParams()` | | --------------- | ------------------- | --------------------------------- | | `/` | `/` | `{}` | | `/user/:id` | `/user/42` | `{ id: "42" }` | | `/:org/:repo` | `/ilha/router` | `{ org: "ilha", repo: "router" }` | | `/docs/**:slug` | `/docs/guide/intro` | `{ slug: "guide/intro" }` | | `/**` | anything | `{}` | Static segments always take priority over `:param` segments. ### `.mount(target, options?)` — browser only Mounts the router into a DOM element or CSS selector. Sets up `popstate` listening and intercepts internal `` clicks automatically. Returns an `unmount` function. | Option | Type | Default | Description | | ---------- | ------------------------ | ----------- | ------------------------------------------------------- | | `hydrate` | `boolean` | `false` | Preserve SSR DOM, don't wipe on first mount | | `registry` | `Record` | `undefined` | Island registry for interactive hydration on navigation | ### `.render(url)` — server only Returns a synchronous HTML string for the matched route. Renders `
    ` when no route matches. ### `.renderHydratable(url, registry, options?, request?)` — server only Async variant of `.render()` that outputs HTML with `data-ilha` hydration markers and serialized loader data. Use this in your SSR handler for the full hydration pipeline. ### `.renderResponse(url, registry, options?, request?)` — server only Structured-envelope variant of `.renderHydratable()` that returns a discriminated union instead of a raw string, letting your server emit proper HTTP status codes: ```ts const res = await router() .route("/protected", protectedPage, authLoader) .renderResponse("/protected", registry); if (res.kind === "redirect") return Response.redirect(res.to, res.status); if (res.kind === "error") return new Response(res.html, { status: res.status }); return new Response(res.html, { headers: { "content-type": "text/html" }, }); ``` | `kind` | Fields | When | | ------------ | --------------------------- | -------------------------------- | | `"html"` | `html`, `status?` | Normal render | | `"redirect"` | `to`, `status` | Loader called `redirect()` | | `"error"` | `status`, `message`, `html` | Loader called `error()` or threw | ### `.hydrate(registry, options?)` — browser only Convenience method combining `.prime()`, `ilha.mount()`, and `.mount()` in one call. This is the recommended client entry point. ```ts pageRouter.hydrate(registry); // With options: pageRouter.hydrate(registry, { root: document.getElementById("root"), target: "#app", }); ``` ## Loaders A loader is a data-fetching function that runs before a page renders. Its return value is passed as input props to the island. ```ts import { loader, redirect, error } from "@ilha/router"; export const load = loader( async ({ params, request, signal }) => { const session = await getSession(request); if (!session) redirect("/login"); const user = await fetchUser(params.id, { signal }); if (!user) error(404, "User not found"); return { user }; }, ); ``` The loader receives a `LoaderContext`: ```ts { params: Record; // matched URL params request: Request; // the incoming request url: URL; // parsed URL signal: AbortSignal; // abort on superseded navigation } ``` On the server, loaders run inside `.renderHydratable()`. You can also call **`.runLoader(url, request?)`** on the builder to execute a loader without rendering HTML. On the client, navigations fetch loader data from the `/__ilha/loader` endpoint before mounting the next island. ### `redirect(to, status?)` Throws a redirect sentinel inside a loader. The router catches it and either issues an HTTP redirect on the server or calls `navigate()` on the client. ```ts redirect("/login"); // 302 by default redirect("/moved", 301); // permanent ``` ### `error(status, message)` Throws a loader error sentinel. On the server, use `.renderResponse()` to intercept it and emit a proper HTTP status code. ```ts error(404, "Not found"); error(403, "Forbidden"); ``` ### `composeLoaders(loaders)` Merges multiple loaders into one. All run concurrently via `Promise.all`. Later loaders win on key collision — the page loader overrides layout loaders for the same key. ```ts const combined = composeLoaders([layoutLoader, pageLoader]); // → { user: …, post: … } ``` Used internally by the pages plugin. Also available for manual composition. ## Navigation helpers ### `navigate(to, options?)` Programmatically navigate to a path. Updates the URL, history stack, and all reactive signals. Duplicate navigations are no-ops. ```ts import { navigate } from "@ilha/router"; navigate("/about"); navigate("/about", { replace: true }); // replaces instead of pushing ``` ### `prefetch(pathWithSearch)` Prefetches loader data for a path in the background. The result is cached and consumed on the next navigation, making the transition feel instant. ```ts prefetch("/user/42"); prefetch("/dashboard?tab=overview"); ``` `RouterLink` calls this automatically on `mouseenter` for links with the `data-prefetch` attribute. ## Hash mode By default the router uses the HTML5 History API (`location.pathname`). That needs a server or host that serves your SPA shell for every URL. When that is not possible — `file://`, Electron, or hosts without SPA fallback — use **hash mode** so the route lives in `location.hash`: ```ts import { setHistoryMode, router } from "@ilha/router"; setHistoryMode("hash"); // once, before .mount() / .hydrate() / prime() router() .route("/", HomePage) .route("/about", AboutPage) .mount("#app"); ``` URLs look like `index.html#/about` or `index.html#/user/42?tab=1`. `navigate("/about")` still takes a logical path (no `#` prefix). `` emits `href="#/…"` in hash mode. **SSR + hydration is not supported in hash mode** — the hash is never sent to the server. Use plain `.mount("#app")` without `{ hydrate: true }`. Loaders can still run on the client via `/__ilha/loader` or `runLoader()`. History mode is **process-global** (`navigate`, `RouterLink`, and `prefetch` share it). Set it once at app entry. ## Route context These signals reflect the current route and are safe to read inside any island on both server and client. ### `useRoute()` Returns reactive accessors for the current route state as a convenience object: ```tsx import ilha from "ilha"; import { useRoute } from "@ilha/router"; const MyPage = ilha.render(() => { const { path, params, search, hash } = useRoute(); return

    User: {params().id}

    ; }); ``` ### `routePath` · `routeParams` · `routeSearch` · `routeHash` The underlying context signals for direct access outside of islands: ```ts import { routePath, routeParams } from "@ilha/router"; routePath(); // → "/user/42" routeParams(); // → { id: "42" } ``` ### `isActive(pattern)` Returns `true` if the current path matches a registered pattern: ```ts import { isActive } from "@ilha/router"; isActive("/about"); // → true / false isActive("/user/:id"); // → true when on any /user/* path ``` ## Built-in islands ### `RouterView` The outlet island rendered by `.mount()` and `.render()`. Wraps the active page island in `
    `. Renders `
    ` when no route matches. ### `RouterLink` A declarative link island. Calls `navigate()` on click and prefetches loader data on `mouseenter`. ```ts RouterLink.toString({ href: "/about", label: "About" }); // → '
    About' ``` Opt a specific link out of prefetching with `data-prefetch="false"`. ## File-system routing `@ilha/router` ships a **pages plugin** (built on [unplugin](https://unplugin.unjs.io/)) that scans `src/pages/`, resolves layout and error boundary chains, and generates a ready-to-use router with zero manual route registration. The same plugin works across bundlers — pick the entry that matches your toolchain: | Bundler | Import | | -------- | ----------------------- | | Vite | `@ilha/router/vite` | | Rspack | `@ilha/router/rspack` | | Rolldown | `@ilha/router/rolldown` | Vite 8+ may use Rolldown under the hood; use `@ilha/router/vite` in `vite.config.ts` either way. Use `@ilha/router/rolldown` when you configure [Rolldown](https://rolldown.rs/) directly (for example with [tsdown](https://tsdown.dev/)). ### Setup **Vite** ```ts // vite.config.ts import { defineConfig } from "vite"; import { pages } from "@ilha/router/vite"; export default defineConfig({ plugins: [pages()], }); ``` **Rspack** ```ts // rspack.config.ts import { rspack } from "@rspack/core"; import { pages } from "@ilha/router/rspack"; export default { plugins: [ pages(), new rspack.HtmlRspackPlugin({ template: "./index.html", }), ], resolve: { extensions: ["...", ".ts", ".tsx"], }, module: { rules: [ { test: /\.ts$/, exclude: /node_modules/, loader: "builtin:swc-loader", options: { jsc: { parser: { syntax: "typescript", }, }, }, type: "javascript/auto", }, { test: /\.tsx$/, exclude: /node_modules/, loader: "builtin:swc-loader", options: { jsc: { parser: { syntax: "typescript", tsx: true, }, transform: { react: { runtime: "automatic", importSource: "ilha", throwIfNamespace: false, }, }, }, }, type: "javascript/auto", }, ], }, }; ``` **Rolldown** (tsdown, Rolldown CLI, or other Rolldown-based tools) ```ts // tsdown.config.ts import { pages } from "@ilha/router/rolldown"; import { defineConfig } from "tsdown"; export default defineConfig({ entry: ["src/client.ts"], plugins: [pages()], }); ``` Add `.ilha/` to `.gitignore`. ### Directory structure ``` src/pages/ +layout.tsx ← root layout (wraps all pages) +error.tsx ← root error boundary index.tsx → / about.tsx → /about (auth)/ ← route group — invisible in the URL +layout.tsx ← layout scoped to (auth) pages only sign-in.tsx → /sign-in sign-up.tsx → /sign-up user/ +layout.tsx ← nested layout (wraps user/* only) +error.tsx ← nested error boundary [id].tsx → /user/:id [id]/ settings.tsx → /user/:id/settings [...slug].tsx → /**:slug ``` ### Filename → pattern mapping | File | Pattern | | -------------------- | ----------- | | `index.tsx` | `/` | | `about.tsx` | `/about` | | `[id].tsx` | `/:id` | | `user/[id].tsx` | `/user/:id` | | `[...slug].tsx` | `/**:slug` | | `(auth)/sign-in.tsx` | `/sign-in` | ### Route groups Folders wrapped in parentheses — `(name)` — organise files without contributing a URL segment. Use them for shared layouts, co-located pages, or logical grouping with no effect on the URL. ### Layouts A `+layout.tsx` wraps every page in its directory and all subdirectories. Layouts compose inside-out — nearest layout is innermost. (`.ts` is also supported if you do not need JSX.) ```tsx // src/pages/+layout.tsx import { defineLayout } from "@ilha/router"; import ilha, { html } from "ilha"; export default defineLayout((Children) => ilha.render( () => html`
    ${Children}
    `, ), ); ``` ### Page loaders Export a `load` function from any page file. The pages plugin detects it automatically, composes it with any layout loaders, and wires it into the router at SSR time. ```tsx // src/pages/user/[id].tsx import { loader } from "@ilha/router"; import ilha from "ilha"; type User = { name: string }; export const load = loader(async ({ params }) => { const user = await fetchUser(params.id); return { user }; }); export default ilha .input<{ user: User }>() .render(({ input }) =>

    {input.user.name}

    ); ``` ### Error boundaries A `+error.tsx` catches rendering errors for pages in its directory. The nearest boundary wins. If it re-throws, the next outer boundary takes over. (`.ts` is also supported if you do not need JSX.) ```tsx // src/pages/+error.tsx import type { ErrorHandler } from "@ilha/router"; import ilha from "ilha"; export default ((err, route) => ilha.render(() => (

    {err.status ?? 500}

    {err.message}

    ))) satisfies ErrorHandler; ``` ### Virtual modules Use the **explicit** `/server` or `/client` suffix — they resolve to different generated files (raw imports for SSR vs `?client` imports for the browser bundle). | Module | Exports | Use for | | ------------------- | ------------------------ | -------------------------------------- | | `ilha:pages/server` | `pageRouter`, `registry` | SSR, prerender, Nitro handlers | | `ilha:pages/client` | `pageRouter`, `registry` | Browser hydration entry | | `ilha:loaders` | — | Server-only side-effect: wires loaders | For **`mode: "static"`** (MPA / pre-rendered HTML), hydrate per page with: ```ts import { pageRouter, registry } from "ilha:pages/client"; pageRouter.hydrateStatic(registry); ``` ### Plugin options Options are the same for every bundler entry (`vite`, `rspack`, `rolldown`): ```ts pages({ dir: "src/pages", // default outDir: ".ilha", // default mode: "spa", // "spa" | "static" (default: "spa") interceptLinks: true, // spa only — false = full page loads on clicks }); ``` - **`mode: "spa"`** — full route graph, SSR/hydration, client navigation. - **`mode: "spa", interceptLinks: false`** — SSR/hydration, but internal links reload the document. - **`mode: "static"`** — registry only on the client; no bundled route graph — use `hydrateStatic`. For advanced use, import `ilhaPages` from any bundler entry and call `.vite()`, `.rspack()`, or `.rolldown()` on the shared factory. ## Route sorting Routes are sorted automatically — no need to order files manually: 1. **Static** paths (`/about`) — highest priority 2. **Parameterised** paths (`/user/:id`) 3. **Wildcard** paths (`/**:slug`) — lowest priority Within the same tier, longer segment counts and alphabetical order act as tiebreakers. ## SSR + hydration flow ``` server client ────────────────────────────────── ──────────────────────────────────── renderHydratable(url, registry) pageRouter.hydrate(registry) → loader runs, props serialized → prime() — syncs route signals → island.hydratable(props, …) → mount(registry) — hydrates islands → data-ilha-state snapshot → mount(target, { hydrate: true }) → data-ilha-props on island → navigation handler activated ``` On navigation, the client fetches `/__ilha/loader?path=…`, receives loader data as JSON, and mounts the next island with hydration markers — no full page reload. ## TypeScript Key exported types: ```ts import type { LoaderContext, // { params, request, url, signal } LoaderFn, // the async function passed to loader() ErrorHandler, // (err, route) => Island — for +error.ts files } from "@ilha/router"; ``` ## Related | Topic | Guide | | -------------------------- | ---------------------------------------------------------------- | | Core `ilha` API | [ilha](/guide/libraries/ilha) | | Shared state across routes | [Store](/guide/libraries/store) | | Form submit handlers | [Store — Forms](/guide/libraries/store#forms) (`preventDefault`) | | Route context signals | [Signals](/guide/helpers/signals) | | Hydratable SSR | [Hydratable](/guide/island/hydratable) | --- # @ilha/store Route: /guide/libraries/store Source: /guide/libraries/store/index.md 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 `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)` · `store(schema)` 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(); store<{ foo: string }>({ foo: "bar" }).build(); // explicit POJO type ``` Or pass a [Standard Schema](https://standardschema.dev) so **every write** (accessors, `setState`, `bind:*`, actions) validates 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). ```ts import { z } from "zod"; const s = store( z.object({ email: z.email().default("ada@example.com"), }), ) .onError(({ error, issues }) => { /* toast, fieldErrors: error.fieldErrors */ }) .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` may be sync or `async`. Return a `Partial` patch (or `Promise` of one) to merge through middleware, or `void` / `Promise` when writes use `ctx.set` only or you only run side effects (e.g. `return void toast.error(...)` after `await`). ```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) | ### `.onError(handler)` Schema-backed stores only. Runs when a commit fails validation — state stays at the last good snapshot. Context: `{ error, source: "validate", patch?, path?, issues?, get() }`. `error` is a `StoreValidationError` (`issues`, `fieldErrors`). Without a handler, `console.error` is used. ### `.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); // ``` ## `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`${cartStore.count()}`, ); export const CartList = ilha.render( () => html`
      ${cartStore.items().map((item) => html`
    • ${item}
    • `)}
    `, ); ``` 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` 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`
    ${errors().name ? html`

    ${errors().name![0]}

    ` : ""} ${errors().email ? html`

    ${errors().email![0]}

    ` : ""}
    `, ); ``` ## TypeScript ```ts import type { StoreBuilder, // the builder type BuiltStore, // the built store type StateAccessor, // : () => T and (value: T) => void DerivedAccessor, // : () => T | undefined, .loading, .value, .error DerivedValue, // : { 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() StoreErrorContext, // { error, source, path?, patch?, issues?, get() } — .onError() StoreValidationError, StoreBindable, // : 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 } 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) | --- # Showcase Route: /guide/resources/showcase Source: /guide/resources/showcase/index.md import { LayerCard } from "areia"; export const SHOWCASE = [ { title: "Ilha Website", image: "/showcase-ilha.jpg", link: "https://ilha.build", }, { title: "Areia UI", image: "/showcase-areia.jpg", link: "https://areia.ilha.build", }, { title: "thojensen.com", image: "/showcase-thojensen.jpg", link: "https://thojensen.com", }, { title: "Imprensa", image: "/showcase-imprensa.jpg", link: "https://imprensa.ilha.build", }, ]; # Showcase