Islands & hydration

The framework renders every page server-side: the SSR engine fills your HTML against the page context, and the browser receives complete, real markup. Most of that markup is static and should stay that way. Islands are the small, explicitly-marked regions that need to become interactive after load.

islands.js is a few-KB, no-build, zero-dependency runtime that hydrates only those marked regions — in place, reusing the server-rendered nodes. No virtual DOM, no re-render, no flicker. See it live on the islands demo.

It builds directly on signals.js: an island's setup function uses the same signal / effect primitives to wire reactivity.

What an island is

An island is any element carrying data-island="name", where name matches a component you registered in JavaScript. Everything not inside a data-island element is never touched — it's pure server HTML.

<article data-island="counter">
  <script type="application/json" data-island-state>{"count": 7}</script>
  <div id="counter-value">7</div>
  <button data-counter-inc>+1</button>
</article>

The registerIsland API

import { registerIsland, signal, effect } from "/islands.js";

registerIsland("counter", (el, initialState) => {
  const count = signal(initialState.count ?? 0);
  const out = el.querySelector("#counter-value");
  effect(() => { out.textContent = String(count()); });
  el.querySelector("[data-counter-inc]")
    .addEventListener("click", () => count(count() + 1));
});

hydration. el is the server-rendered root; initialState is the parsed initial state (always an object). Mutate el in place — the return value is ignored. Wire signals/effects/listeners to the existing nodes; do not replace them.

Registration order doesn't matter: the runtime defers its first hydration scan to a microtask, so inline <script type="module"> registrations on the page run before islands are hydrated.

Initial state from the server markup

Hydrated signals start at exactly the value the server printed, so there is no flash of changed content. The runtime reads initial state from two sources, in priority order:

  1. A child <script type="application/json" data-island-state>{…}</script>

best for rich or nested state; it isn't an attribute, so no HTML escaping.

  1. A data-state='{…}' attribute on the island root — the same convention

signals.js uses.

Whichever is found is JSON-parsed and passed as initialState. If neither is present (or the JSON is invalid), initialState is {}.

This is the contract that kills the flash: render the value once on the server, print it in the markup, seed the signal from it. The first interactive paint equals the SSR paint.

How it differs from full-page reactivity

signals.js auto-scans [data-scope] roots and wires declarative data-text / data-show / data-model / data-for directives generically — you write no JavaScript. Islands are named components: the server decides which islands exist and what state each starts with, and you write the wiring once in JS via registerIsland. Reach for directives when the markup can describe the behavior; reach for islands when a region needs real component logic, or when you want a hard boundary between "interactive" and "static".

The two compose: an island's setupFn is free to import and use signal / effect (re-exported from /islands.js), and may even run the signals.js directive binder over its own subtree.

No virtual DOM, no flicker — by design

Hydration never creates or replaces nodes. It attaches behavior to the DOM the server already sent. There is no diffing, no reconciliation, and no moment where the UI is rebuilt from a JS-side model. Remove islands.js and the page still renders — it just stops being interactive. Win by subtraction.

Verifying in the browser

Open /islands and check the console:

[islands] hydrated counter
[islands] hydrated toggle

buttons mutate it live.