Templating
JSX, lowercase events, and lists from data.
Prefer JSX. Interpolated values escape. Use class, not className.
const const Hello: ({ name }: {
name: string;
}) => View
Hello = ({ name: stringname }: { name: stringname: string }) => <JSX.IntrinsicElements.p: HTMLAttributes<HTMLParagraphElement>p class?: Attrclass="lede">Hello, {name: stringname}</JSX.IntrinsicElements.p: HTMLAttributes<HTMLParagraphElement>p>;
Use h() when a file cannot use the JSX runtime. See Without JSX.
Events
Event props are lowercase native names: onclick, onchange, onsubmit. Use a plain function. You do not need a special action wrapper on the client.
import { const atom: AtomFnatom } from "ilha";
const const Counter: () => ViewCounter = () => {
const const count: AtomHandle<number>count = atom<number>(init: number | Atom<number> | Effect<number, unknown, AtomRegistry> | Stream<number, unknown, AtomRegistry>, options?: AtomOptions<number> | undefined): AtomHandle<number>atom(0);
return (
<JSX.IntrinsicElements.button: ButtonHTMLAttributesbutton type?: string | undefinedtype="button" EventProps<HTMLButtonElement>.onclick?: ((event: Targeted<HTMLButtonElement, MouseEvent>) => void) | undefinedonclick={() => const count: AtomHandle<number>count.AtomHandle<number>.update: (f: (current: number) => number) => voidupdate((n: numbern) => n: numbern + 1)}>
{const count: AtomHandle<number>count}
</JSX.IntrinsicElements.button: ButtonHTMLAttributesbutton>
);
};
Disclosure and dialog elements report their own state changes: ontoggle and onbeforetoggle on <details>, onclose and oncancel on <dialog>. The handler’s currentTarget carries the typed element:
const const Panel: () => ViewPanel = () => (
<JSX.IntrinsicElements.details: DetailsHTMLAttributesdetails
EventProps<HTMLDetailsElement>.ontoggle?: ((event: Targeted<HTMLDetailsElement, Event>) => void) | undefinedontoggle={(e: Targeted<HTMLDetailsElement, Event>e) => {
var console: ConsoleThe `console` module provides a simple debugging console that is similar to the
JavaScript console mechanism provided by web browsers.
The module exports two specific components:
* A `Console` class with methods such as `console.log()`, `console.error()` and `console.warn()` that can be used to write to any Node.js stream.
* A global `console` instance configured to write to [`process.stdout`](https://nodejs.org/docs/latest-v24.x/api/process.html#processstdout) and
[`process.stderr`](https://nodejs.org/docs/latest-v24.x/api/process.html#processstderr). The global `console` can be used without importing the `node:console` module.
_**Warning**_: The global console object's methods are neither consistently
synchronous like the browser APIs they resemble, nor are they consistently
asynchronous like all other Node.js streams. See the [`note on process I/O`](https://nodejs.org/docs/latest-v24.x/api/process.html#a-note-on-process-io) for
more information.
Example using the global `console`:
```js
console.log('hello world');
// Prints: hello world, to stdout
console.log('hello %s', 'world');
// Prints: hello world, to stdout
console.error(new Error('Whoops, something bad happened'));
// Prints error message and stack trace to stderr:
// Error: Whoops, something bad happened
// at [eval]:5:15
// at Script.runInThisContext (node:vm:132:18)
// at Object.runInThisContext (node:vm:309:38)
// at node:internal/process/execution:77:19
// at [eval]-wrapper:6:22
// at evalScript (node:internal/process/execution:76:60)
// at node:internal/main/eval_string:23:3
const name = 'Will Robinson';
console.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to stderr
```
Example using the `Console` class:
```js
const out = getStreamSomehow();
const err = getStreamSomehow();
const myConsole = new console.Console(out, err);
myConsole.log('hello world');
// Prints: hello world, to out
myConsole.log('hello %s', 'world');
// Prints: hello world, to out
myConsole.error(new Error('Whoops, something bad happened'));
// Prints: [Error: Whoops, something bad happened], to err
const name = 'Will Robinson';
myConsole.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to err
```console.Console.log(message?: any, ...optionalParams: any[]): void (+2 overloads)Prints to `stdout` with newline. Multiple arguments can be passed, with the
first used as the primary message and all additional used as substitution
values similar to [`printf(3)`](http://man7.org/linux/man-pages/man3/printf.3.html)
(the arguments are all passed to [`util.format()`](https://nodejs.org/docs/latest-v24.x/api/util.html#utilformatformat-args)).
```js
const count = 5;
console.log('count: %d', count);
// Prints: count: 5, to stdout
console.log('count:', count);
// Prints: count: 5, to stdout
```
See [`util.format()`](https://nodejs.org/docs/latest-v24.x/api/util.html#utilformatformat-args) for more information.log(e: Targeted<HTMLDetailsElement, Event>e.currentTarget: HTMLDetailsElementcurrentTarget.HTMLDetailsElement.open: booleanThe **`open`** property of the HTMLDetailsElement interface is a boolean value reflecting the open HTML attribute, indicating whether the <details>'s contents (not counting the <summary>) is to be shown to the user.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/HTMLDetailsElement/open)open);
}}
>
<JSX.IntrinsicElements.summary: HTMLAttributes<HTMLElement>summary>More</JSX.IntrinsicElements.summary: HTMLAttributes<HTMLElement>summary>
<JSX.IntrinsicElements.p: HTMLAttributes<HTMLParagraphElement>p>Details</JSX.IntrinsicElements.p: HTMLAttributes<HTMLParagraphElement>p>
</JSX.IntrinsicElements.details: DetailsHTMLAttributesdetails>
);
On a server island, bind oxidejs actions with .with(...) on event props so SSR can serialize frame sentinels. Plain client islands keep plain handlers. See Server islands.
Touch the DOM node
Pass ref to read the live element. You receive the node on mount and null on unmount:
const const Field: () => ViewField = () => (
<JSX.IntrinsicElements.input: InputHTMLAttributesinput
type?: string | undefinedtype="text"
ref?: ((el: (HTMLInputElement & Element) | null) => void) | undefinedref={(el: (HTMLInputElement & Element) | nullel) => {
el: (HTMLInputElement & Element) | nullel?.HTMLOrSVGElement.focus(options?: FocusOptions): void[MDN Reference](https://developer.mozilla.org/docs/Web/API/HTMLElement/focus)focus();
}}
/>
);
Treat the null call as cleanup: disconnect observers and release library handles there. Do not keep the node past null; a rerender may replace it. See Render and hydrate for a full vanilla-library recipe.
Lists
Atoms hold data. Map an array during render:
import { const atom: AtomFnatom } from "ilha";
type type Item = {
id: string;
text: string;
}
Item = { id: stringid: string; text: stringtext: string };
const const List: () => ViewList = () => {
const const items: AtomHandle<Item[]>items = atom<Item[]>(init: Item[] | Atom<Item[]> | Effect<Item[], unknown, AtomRegistry> | Stream<Item[], unknown, AtomRegistry>, options?: AtomOptions<Item[]> | undefined): AtomHandle<Item[]>atom<type Item = {
id: string;
text: string;
}
Item[]>([{ id: stringid: "1", text: stringtext: "One" }]);
return (
<JSX.IntrinsicElements.ul: HTMLAttributes<HTMLUListElement>ul>
{const items: AtomHandle
() => Item[]
items().Array<Item>.map<View>(callbackfn: (value: Item, index: number, array: Item[]) => View, thisArg?: any): View[]Calls a defined callback function on each element of an array, and returns an array that contains the results.map((item: Itemitem) => (
<JSX.IntrinsicElements.li: LiHTMLAttributesli key?: string | number | undefinedkey={item: Itemitem.id: stringid}>{item: Itemitem.text: stringtext}</JSX.IntrinsicElements.li: LiHTMLAttributesli>
))}
</JSX.IntrinsicElements.ul: HTMLAttributes<HTMLUListElement>ul>
);
};
When items changes, the component reruns and morphs the list. Keys on li reuse DOM nodes when the array shifts. Components accept key the same way with no extra prop type — JSX.IntrinsicAttributes covers it.
A keyed component keeps its state while the view keeps the key. When the parent reruns, the component reruns only if its props changed, so a long keyed list costs what changed in it. Props compare shallowly: an inline object, an inline function, or a JSX element child counts as a change on every render. The component still reruns on its own when an atom it reads changes.
When a render drops the key, the component unmounts: its watch.once cleanup runs, and the same key later mounts a new component with fresh state. A key that moves to a different component also mounts it fresh.
Give each keyed component a key that is unique within the component that renders it, not only within its list. Two lists in one component that both key their rows by index share keys. Ilha warns once and keeps the duplicate’s state by position instead.
For live server lists, paint a Stream into a hole. See Streams.
Keep user state across rerenders
Rerenders morph the live DOM instead of replacing it. Form values, focus, caret position, and a user-opened <details> or <dialog> survive by default — the morph keeps open unless your render sets it explicitly.
Reused components, keyed rows, and atom holes stay attached while their parent rerenders. An <iframe>, a playing <video>, a scrolled container, or a custom element inside them keeps its state. An empty contenteditable element keeps what the user typed; give it children when you want the render to control its content.
When a render must not overwrite an attribute that code outside the render changed, mark it with data-morph-preserve:
const const Card: () => ViewCard = () => (
<JSX.IntrinsicElements.div: HTMLAttributes<HTMLDivElement>div
data-morph-preserve: stringdata-morph-preserve="title"
title?: Attrtitle="rendered"
EventProps<HTMLDivElement>.onmouseenter?: ((event: Targeted<HTMLDivElement, MouseEvent>) => void) | undefinedonmouseenter={(e: Targeted<HTMLDivElement, MouseEvent>e) => {
e: Targeted<HTMLDivElement, MouseEvent>e.currentTarget: HTMLDivElementcurrentTarget.HTMLElement.title: stringThe **`HTMLElement.title`** property represents the title of the element: the text usually displayed in a 'tooltip' popup when the mouse is over the node.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/HTMLElement/title)title = "seen";
}}
>
content
</JSX.IntrinsicElements.div: HTMLAttributes<HTMLDivElement>div>
);
The handler writes a live title, and later rerenders keep it. List attribute names separated by spaces.
Add the children token when code outside the render owns an element’s content, such as a chart or editor library:
const const Chart: () => ViewChart = () => (
<JSX.IntrinsicElements.div: HTMLAttributes<HTMLDivElement>div class?: Attrclass="chart" data-morph-preserve: stringdata-morph-preserve="children">
<JSX.IntrinsicElements.p: HTMLAttributes<HTMLParagraphElement>p>Loading chart…</JSX.IntrinsicElements.p: HTMLAttributes<HTMLParagraphElement>p>
</JSX.IntrinsicElements.div: HTMLAttributes<HTMLDivElement>div>
);
The first paint inserts the placeholder. Later rerenders still sync the element’s attributes, but they leave its children alone. Tokens combine, as in data-morph-preserve="title children". Code can also add the attribute to a live element at runtime to claim it.
Control form fields
You control a field by passing its value prop. Each rerender then resets the live field to the rendered value, undoing edits you did not accept:
import { const atom: AtomFnatom } from "ilha";
const const Settings: () => ViewSettings = () => {
const const size: AtomHandle<string>size = atom<string>(init: string | Atom<string> | Effect<string, unknown, AtomRegistry> | Stream<string, unknown, AtomRegistry>, options?: AtomOptions<string> | undefined): AtomHandle<string>atom("m");
const const notify: AtomHandle<boolean>notify = atom<boolean>(init: boolean | Atom<boolean> | Effect<boolean, unknown, AtomRegistry> | Stream<boolean, unknown, AtomRegistry>, options?: AtomOptions<boolean> | undefined): AtomHandle<boolean>atom(false);
return (
<JSX.IntrinsicElements.form: FormHTMLAttributesform>
<JSX.IntrinsicElements.select: SelectHTMLAttributesselect value?: Bindable<string | number> | undefinedvalue={const size: AtomHandle
() => string
size()} EventProps<HTMLSelectElement>.onchange?: ((event: Targeted<HTMLSelectElement, Event>) => void) | undefinedonchange={(e: Targeted<HTMLSelectElement, Event>e) => const size: AtomHandle<string>size.AtomHandle<string>.set: (next: string) => voidset(e: Targeted<HTMLSelectElement, Event>e.currentTarget: HTMLSelectElementcurrentTarget.HTMLSelectElement.value: stringThe **`HTMLSelectElement.value`** property contains the value of the first selected <option> element associated with this <select> element.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/HTMLSelectElement/value)value)}>
<JSX.IntrinsicElements.option: OptionHTMLAttributesoption value?: string | number | undefinedvalue="s">Small</JSX.IntrinsicElements.option: OptionHTMLAttributesoption>
<JSX.IntrinsicElements.option: OptionHTMLAttributesoption value?: string | number | undefinedvalue="m">Medium</JSX.IntrinsicElements.option: OptionHTMLAttributesoption>
</JSX.IntrinsicElements.select: SelectHTMLAttributesselect>
<JSX.IntrinsicElements.input: InputHTMLAttributesinput
type?: string | undefinedtype="checkbox"
checked?: Bindable<boolean> | undefinedchecked={const notify: AtomHandle
() => boolean
notify()}
EventProps<HTMLInputElement>.onchange?: ((event: Targeted<HTMLInputElement, Event>) => void) | undefinedonchange={(e: Targeted<HTMLInputElement, Event>e) => const notify: AtomHandle<boolean>notify.AtomHandle<boolean>.set: (next: boolean) => voidset(e: Targeted<HTMLInputElement, Event>e.currentTarget: HTMLInputElementcurrentTarget.HTMLInputElement.checked: booleanThe **`checked`** property of the HTMLInputElement interface specifies the current checkedness of the element; that is, whether the form control is checked or not.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/HTMLInputElement/checked)checked)}
/>
<JSX.IntrinsicElements.textarea: TextareaHTMLAttributestextarea value?: Bindable<string> | undefinedvalue="Notes" />
</JSX.IntrinsicElements.form: FormHTMLAttributesform>
);
};
| Field | Controlled by |
|---|---|
<input> |
value, or checked for checkboxes and radios |
<textarea> |
value or its children |
<select> |
value, or selected on its options |
false and "" are controlled values: checked={false} unchecks the box on every rerender. Omit the prop, or pass undefined or null, to leave the field uncontrolled so the user’s input survives.
Raw HTML
Interpolated strings escape. You wrap pre-rendered markup in unsafe() to paint it as HTML:
import { const unsafe: (html: string) => UnsafeHtmlMark a string as pre-rendered HTML and paint it without escaping.
Use it for markup you built or sanitized yourself — SVG from an external
library, Markdown rendered to HTML, embed codes. Never pass untrusted
user input: unlike interpolated strings, this content is not escaped and
scripts inserted this way do not run, but event attributes and other
markup still apply.unsafe } from "ilha";
const const Badge: ({ svg }: {
svg: string;
}) => View
Badge = ({ svg: stringsvg }: { svg: stringsvg: string }) => <JSX.IntrinsicElements.span: HTMLAttributes<HTMLSpanElement>span>{function unsafe(html: string): UnsafeHtmlMark a string as pre-rendered HTML and paint it without escaping.
Use it for markup you built or sanitized yourself — SVG from an external
library, Markdown rendered to HTML, embed codes. Never pass untrusted
user input: unlike interpolated strings, this content is not escaped and
scripts inserted this way do not run, but event attributes and other
markup still apply.unsafe(svg: stringsvg)}</JSX.IntrinsicElements.span: HTMLAttributes<HTMLSpanElement>span>;
Use it for markup you built or sanitized yourself: SVG from an external library, Markdown rendered to HTML, embed codes. Never pass untrusted user input. Inserted scripts do not run. Event attributes still apply. The URL and style sanitizers that guard JSX props are skipped. Sanitize anything you did not author.