Skip to content
Ilha
Esc
navigateopen⌘Jpreview
On this page

JSX-less

Use Ilha with html template tags instead of JSX — no JSX runtime, no compiler, no build transform.

You can author islands entirely with html template tags instead of JSX. This is the core reactive API with zero JSX tooling: no jsx setting, no JSX runtime import, no Babel or SWC transform, and no tsconfig tweak. You write ordinary .js or .ts files and run them anywhere — Node, Bun, Deno, or the browser.

import { ilha, html, state } from "ilha";

export const Counter = ilha(() => {
  const count = state(0);

  return html`
    <button onclick=${() => count((value) => value + 1)}>
      count: ${count()}
    </button>
  `;
});

html is a plain tagged template. It returns a RawHtml object and applies Ilha’s escaping and composition rules, so it is not just a wrapper around a string — the same event, binding, and interpolation semantics you know from JSX live in the template.

Primitives work the same

state(), derived(), action(), effect(), and onError() behave identically in a JSX-less island. Only the markup syntax changes. The signal-accessor shorthand also works: interpolate count directly and Ilha calls it for you.

import { derived, ilha, html, state } from "ilha";

export const Totals = ilha(() => {
  const count = state(0);
  const doubled = derived(() => count() * 2);

  return html`
    <p>${count} doubled is ${doubled()}</p>
    <button onclick=${() => count((value) => value + 1)}>
      +1
    </button>
  `;
});

Events and bindings

Lowercase native event props take a function interpolation. Ilha attaches the listener with addEventListener on mount, never serializing the function into the emitted HTML.

import { ilha, html, state } from "ilha";

export const NameForm = ilha(() => {
  const name = state("");

  return html`
    <form onsubmit=${(event) => event.preventDefault()}>
      <input bind:value=${name} />
      <p>Hello, ${name}.</p>
    </form>
  `;
});

bind:value, bind:checked, bind:group, bind:this, and the other bind:* forms are all available, as are the event modifiers :once, :capture, :passive, and :abortable.

Conditionals and lists

A null or undefined interpolation renders as nothing, so conditionals are just expressions:

import { ilha, html, state } from "ilha";

export const Notice = ilha(() => {
  const error = state(null);

  return html`
    <div>
      ${error() ? html`<p role="alert">${error()}</p>` : ""}
    </div>
  `;
});

Render a list by mapping each item to an html result. Array items are processed recursively with no comma joining, and html results pass through unescaped.

import { ilha, html, state } from "ilha";

export const Fruits = ilha(() => {
  const fruits = state(["apple", "banana", "cherry"]);

  return html`
    <ul>
      ${fruits().map((fruit) => html`<li>${fruit}</li>`)}
    </ul>
  `;
});

Composition

Reuse markup by splitting it into plain functions that return an html result and calling them where you need them:

import { html } from "ilha";

const Badge = (label) =>
  html`<span class="badge">${label}</span>`;

export const Card = ilha(
  () => html` <article>${Badge("New")}</article> `,
);

A nested island is interpolated as Island(props) (or as Island when it needs no props) to create an independent island boundary:

import { ilha, html, state } from "ilha";

const Counter = ilha(() => {
  const count = state(0);
  return html`<button onclick=${() => count((v) => v + 1)}>
    ${count}
  </button>`;
});

export const App = ilha(
  () => html` <section>${Counter()} ${Counter()}</section> `,
);

Use Counter.key("stable")(props) for keyed identity. Interpolating a plain string that contains markup requires raw() (or an html result) so it passes through safely.

import { html, raw } from "ilha";

const icon = `<svg aria-hidden="true">…</svg>`;

export const Dropdown = ilha(
  () => html` <button>Options ${raw(icon)}</button> `,
);

Server rendering and hydration

The same island renders or hydrates with the same APIs you already use:

import { ilha, html, state } from "ilha";

const Counter = ilha(() => {
  const count = state(0);
  return html`<button onclick=${() => count((v) => v + 1)}>
    ${count}
  </button>`;
});

// SSR
Counter.toString();
await Counter.toStringAsync();

// Emit hydration markup, then mount on the client
await Counter.hydratable(props, {
  name: "Counter",
  snapshot: true,
});

The only difference from the JSX path is the markup syntax. Interpolation escaping, URL/scheme filtering, script and style data via json() and css(), and the rest of the html rules apply unchanged.

When to pick JSX over html

Reach for html when you want no build step — server-only rendering, a single <script> tag, a small script, or a project that does not already transform TSX. Reach for JSX when you prefer element syntax and type-aware JSX props, or when most of your codebase is already TSX. There is no runtime gap between the two; authoring style is the only difference.

For more on html escaping, lists, whitespace handling, and the full interpolation table, see Helpers.

Was this page helpful?