Docs

Islands: interactivity without hydration

A @nifrajs/web-vanilla page ships zero framework runtime. When one corner needs to be interactive, you don't turn on hydration - you mount an island over a server-rendered element. Write it as plain DOM code, or bind signals to the markup by name. Either way nifra check reads the island and the markup together.

Why islands, not hydration

Vanilla routes set export const hydrate = false in their .backend.ts half - there is no client framework to hydrate with, and that is the point. The five framework adapters give you a component runtime and pay for it in client JS and in failure modes (stale closures, hydration mismatch, what-re-runs-when) that trip up generated code. Islands keep the HTML the server sent and attach behavior to it. Reach for a framework adapter when you need client routing or nested views; reach for islands for widgets and small apps on an otherwise-static page.

Mount an island

Any element with data-island="id" is a host; its props are inline JSON in data-island-props. Bundle one entry (in frontend/) that calls mountIslands with an enhancer per id, and load it with the islandScripts export of the route's .backend.ts half. Calling mountIslands again after a soft navigation mounts only new hosts, and the disposer it returns tears down every island it mounted.

TS
// doc-check: skip - server-rendered island host (HTML, not a checkable module).
// In your @nifrajs/web-vanilla page, mark any element as a host and give it inline JSON props:
html`<div data-island="counter" data-island-props="${JSON.stringify({ start: 0 })}">
  <output>0</output>
  <button type="button">+1</button>
</div>`

There are two ways to write the enhancer, and one page can mix both.

Imperative islands: defineIsland

TS
// doc-check: skip - browser DOM enhancer, typechecked in @nifrajs/web under its DOM lib.
import { defineIsland, mountIslands } from "@nifrajs/web/islands"

// defineIsland pins the data-island-props type; the body is plain imperative DOM. ALWAYS return the
// teardown for anything you wire - mountIslands calls it on soft-nav (NF-C020 warns if you forget).
const counter = defineIsland<{ start: number }>((el, props) => {
  let n = props.start
  const out = el.querySelector("output")!
  const onClick = () => { out.textContent = String(++n) }
  el.querySelector("button")?.addEventListener("click", onClick)
  return () => el.querySelector("button")?.removeEventListener("click", onClick)
})

// One bundle entry, loaded via the route's `islandScripts`.
mountIslands({ counter })

defineIsland<Props> is a zero-cost identity helper - its only job is to type props and el so the body needs no cast. Read the DOM, add listeners, return the cleanup. The returned function runs on teardown; forget it and every navigation leaks the listener - nifra check flags exactly that as NF-C020.

Reactive islands: bindIsland

For local state, mount the host with bindIsland. The setup declares named state with state(key, fallback) - seeded from the island's props when they carry the key - and returns its event handlers. The markup then binds them by name:

TS
// doc-check: skip - server-rendered island host (HTML, not a checkable module).
html`<form data-island="search" data-island-props="${JSON.stringify({ query: "" })}"
    data-bind-on="submit:go">
  <input name="q" data-bind-value="query" />
  <button data-bind-attr="disabled:busy">Search</button>
  <p data-bind-show="busy">Searching...</p>
  <p><span data-bind-text="count">0</span> results</p>
</form>`
TS
// doc-check: skip - browser DOM enhancer, typechecked in @nifrajs/web under its DOM lib.
import { bindIsland, mountIslands } from "@nifrajs/web/islands"

const search = bindIsland(({ state, onDispose }) => {
  // Every data-bind-* name in the markup is a state key here, seeded from data-island-props.
  const query = state("query", "")
  const busy = state("busy", false)
  const count = state("count", 0)
  const timer = setInterval(() => console.log("still searching for", query()), 5000)
  onDispose(() => clearInterval(timer))

  // The returned handlers are what data-bind-on names.
  return {
    go: async (event) => {
      event.preventDefault()
      busy.set(true)
      try {
        const hits: unknown[] = await fetch("/api/search?q=" + encodeURIComponent(query())).then((r) => r.json())
        count.set(hits.length)
      } finally {
        busy.set(false)
      }
    },
  }
})

mountIslands({ search })
AttributeEffect
data-bind-text="count"Sets the element's text to the signal's value.
data-bind-show="open"Hides the element while the signal is falsy.
data-bind-class="active:open"Toggles a class per pair.
data-bind-attr="aria-expanded:open"Sets an attribute per pair, removing it for false or null. Never an on* attribute or srcdoc, and a URL attribute takes only an http(s), mailto, tel or relative URL.
data-bind-value="query"Two-way: an input, select or textarea writes the signal back.
data-bind-on="click:add"Adds a returned handler as an event listener per pair.

Values are names, never code, so markup cannot inject behavior. A binding belongs to its nearest island, and nothing inside a data-island-ignore element binds or mounts - render user HTML there. Because both sides are literal, nifra check matches them: NF-C047 warns when a name has no state("...") key or returned handler, and NF-C048 when a binding sits where no bindIsland host will ever walk it.

Signals

The reactive core is exported for code that needs more than markup bindings. A read inside an effect or computed subscribes; batch runs the effects once after several writes, and untrack reads without subscribing. Effects run after every computed they read has settled, so they never see a half-updated value.

TS
import { batch, computed, effect, signal, untrack } from "@nifrajs/web/islands"

// A signal is read by calling it and written with .set. Reads inside an effect or computed subscribe.
const price = signal(20)
const quantity = signal(1)
const total = computed(() => price() * quantity())

const stop = effect(() => console.log("total", total(), "at", untrack(price)))
batch(() => {
  price.set(25)
  quantity.set((n) => n + 1)
}) // the effect runs once, after both writes: logs "total 50 at 25"
stop()

Everything an enhancer creates while it sets up - effects, computeds, lists, resources - is owned by its island and stops when the island unmounts. onDispose(fn) covers timers and requests the setup starts itself. An effect created later, inside an event handler, is not owned: create it during setup.

Keyed lists and async state

bindList(source, container, { key, create, update }) renders a list signal into a container. A surviving item keeps its node, so focus and input state survive a reorder, and only the nodes out of order move. resource(source, fetcher) keeps an async value with a pending, ready or error state; a newer key aborts the older request, and an unmounted island drops a late result.

TS
// doc-check: skip - browser DOM enhancer, typechecked in @nifrajs/web under its DOM lib.
import { bindIsland, bindList, mountIslands, resource } from "@nifrajs/web/islands"

type Hotel = { id: string; name: string }

const hotels = bindIsland(({ root, state }) => {
  const city = state("city", "paris")
  // A newer city aborts the older request; only the newest result lands.
  const found = resource(city, (c, abort) =>
    fetch("/api/hotels?city=" + encodeURIComponent(c), { signal: abort }).then((r): Promise<Hotel[]> => r.json()),
  )
  const list = root.querySelector("ul")
  if (list)
    bindList(() => found().value ?? [], list, {
      key: (h) => h.id, // a stable id, never a position (NF-C022)
      create: () => document.createElement("li"),
      update: (li, h) => {
        li.textContent = h.name
      },
    })
  // A <select data-bind-value="city"> in the markup writes city; nothing else to return.
})

mountIslands({ hotels })

Coordinate islands with a typed bus

Islands are isolated by design - each enhances one element and shares no state. When two must talk (an “add to cart” button and a cart badge), use a createIslandBus: one typed channel, created once and closed over by each enhancer. No implicit global, so concurrent renders and tests never cross-talk.

TS
import { createIslandBus } from "@nifrajs/web/islands"

// One typed channel, created once and closed over by every enhancer that needs it.
type CartEvents = { "cart:add": { sku: string }; "cart:count": number }
const bus = createIslandBus<CartEvents>()

const off = bus.on("cart:count", (n) => console.log("cart now has", n, "items"))
bus.emit("cart:add", { sku: "ABC-1" })
off() // unsubscribe - also usable directly as an island's cleanup

The bus is synchronous, in-memory, and carries no history - a subscriber sees only events emitted after it subscribes. A throwing handler is isolated from the others. Because on() returns its own unsubscribe, an enhancer whose only job is to listen can return it directly as its cleanup:

TS
// doc-check: skip - browser DOM enhancer, typechecked in @nifrajs/web under its DOM lib.
import { createIslandBus, defineIsland, mountIslands } from "@nifrajs/web/islands"

type CartEvents = { "cart:add": { sku: string }; "cart:count": number }
const bus = createIslandBus<CartEvents>()

// Two islands that never import each other - they meet only on the bus.
const addButton = defineIsland<{ sku: string }>((el, props) => {
  const onClick = () => bus.emit("cart:add", { sku: props.sku })
  el.addEventListener("click", onClick)
  return () => el.removeEventListener("click", onClick)
})

let count = 0
const badge = defineIsland((el) => {
  const unsubscribe = bus.on("cart:add", () => {
    count += 1
    el.textContent = String(count)
    bus.emit("cart:count", count)
  })
  return unsubscribe // the on() unsubscribe IS the cleanup
})

mountIslands({ addButton, badge })

Pattern: filter drives results

The same shape scales to any “one island changes, another reacts” case. Each island owns only its own DOM; they meet on the channel, never in a shared store.

TS
// doc-check: skip - browser DOM enhancer, typechecked in @nifrajs/web under its DOM lib.
import { createIslandBus, defineIsland, mountIslands } from "@nifrajs/web/islands"

// filter -> results, one directional channel. The results island owns its own DOM; the filter
// island owns none of it. No shared store, no reactive tree - a message and two enhancers.
const bus = createIslandBus<{ "filter:set": string }>()

const filter = defineIsland((el) => {
  const input = el.querySelector("input")!
  const onInput = () => bus.emit("filter:set", input.value.toLowerCase())
  input.addEventListener("input", onInput)
  return () => input.removeEventListener("input", onInput)
})

const results = defineIsland((el) =>
  bus.on("filter:set", (q) => {
    for (const row of el.querySelectorAll<HTMLElement>("[data-name]")) {
      const name = row.dataset.name ?? ""
      row.hidden = q !== "" && !name.toLowerCase().includes(q)
    }
  }),
)

mountIslands({ filter, results })

Refresh part of the page: swap

A search form or a “more” link on a vanilla page reloads the whole document. To refresh one region instead, mark it with data-swap-slot, render its content with slot() from @nifrajs/web-vanilla, and point the link or form at it with data-swap="replace:<slot>" or data-swap="append:<slot>":

TS
// doc-check: skip - html template markup, which the sample check cannot tell from JSX.
import { html, slot } from "@nifrajs/web-vanilla"

type Books = { q: string; page: number; titles: string[]; more: boolean }

export default ({ data }: { data: Books }) => html`
  <form action="/books" data-swap="replace:results">
    <input name="q" value="${data.q}" />
    <button>Search</button>
  </form>
  <ul data-swap-slot="results">${slot("results", [
    ...data.titles.map((title) => html`<li data-island="book">${title}</li>`),
    data.more
      ? html`<li><a href="/books?q=${encodeURIComponent(data.q)}&page=${data.page + 1}" data-swap="append:results">More</a></li>`
      : "",
  ])}</ul>`

Then call enableSwap from @nifrajs/web/islands/swap in place of mountIslands in the island entry. A page that does not import it pays nothing.

TS
// doc-check: skip - browser DOM entry, typechecked in @nifrajs/web under its DOM lib.
import { defineIsland } from "@nifrajs/web/islands"
import { enableSwap } from "@nifrajs/web/islands/swap"

const book = defineIsland((el) => {
  const onClick = () => el.classList.toggle("picked")
  el.addEventListener("click", onClick)
  return () => el.removeEventListener("click", onClick)
})

// In place of mountIslands: mounts the islands, then refreshes slots from data-swap links and forms.
enableSwap({ book })

The link or GET form makes its usual request with an x-nifra-swap header. The server runs the page as usual - middleware and loaders - and answers with that slot's HTML alone, so the fragment is a slice of the real page, never a second template. replace swaps the slot's content and, for a GET, pushes the URL, so a filtered list survives a reload and back and forward work. append adds to it, and a link or form inside the slot is spent: the slot's child that holds it is removed when the next page arrives.

Any other answer - a redirect, another origin, a page without that slot, an error - loads the URL normally, as does a page whose Trusted Types policy refuses the HTML. A swapped slot joins the current page, so that page's Content-Security-Policy governs it. POST forms submit natively, so the browser applies the response's security policy, starts its scripts and shows the action result. A form POST is sent once. Without the script, links and forms work as plain HTML. A loading slot carries aria-busy="true", a newer swap of the same slot cancels the older one, and a failed request leaves the page as it is and sets data-swap-error on the link or form. Islands inside a slot mount with it and unmount when it is replaced, so interactivity inside a slot comes from islands in it, not from bindings of an island around it. Only @nifrajs/web-vanilla pages answer a swap.

A swap fetch bypasses the browser cache. Documents and fragments vary on x-nifra-swap; fragments are private, no-store, and ISR never serves or stores one. A CDN that ignores Vary, such as a Cloudflare zone, needs its cache rule to bypass requests carrying x-nifra-swap; nifra cdn-check tests that. nifra check reads both sides: NF-C049 flags data-swap on anything but a link or form, or a mode other than replace and append; NF-C050 a slot name nothing declares; NF-C051 a slot whose content is not its slot() alone; NF-C052 a URL whose page renders no such slot; and NF-C048 a binding inside a slot that belongs to an island outside it.

Load strategies

A host's data-island-strategy defers its enhancer: load (default), idle, visible (mount when it scrolls into view), or media with the query in data-island-media. A host with no matching enhancer, or an invalid strategy, stays inert server-rendered HTML - forward-compatible, never a runtime error. The scheduling itself is @nifrajs/island-trigger's scheduleTrigger, which has no DOM or framework dependency, for a runtime of your own.