Islands & Signals

A PRISM page is rendered to HTML on the server: your components' code is not sent to the browser. When part of a page has to react to clicks or typing, you make it an island: a component rendered to HTML on the server, then made interactive in the browser. The rest of the page stays static HTML.

PRISM gives you two ways to do this:

  • a "use signals" component: you write it in JSX with signals, and the server renders it and compiles it for the browser;
  • a hand-written script in public/islands/, loaded with a <script> tag.

Declaring a signals island

An island is a file under src/components/ or src/islands/ (subfolders included) whose first line is the "use signals" directive, followed by the signals JSX pragma:

// src/components/Counter.tsx
"use signals";
/** @jsxImportSource @bext-stack/framework/signals */

import { signal, computed } from "@bext-stack/framework/signals";

export default function Counter(props: { initial?: number }) {
  const count = signal(props.initial ?? 0);
  const doubled = computed(() => count.value * 2);
  return (
    <div>
      <p>
        Count: <strong>{count.value}</strong> (×2 = {doubled.value})
      </p>
      <button type="button" onClick={() => { count.value++; }}>+1</button>
    </div>
  );
}

The island's name is the file name without its extension (Counter here). The component is the file's default export.

Placing it in a page

In a regular page or layout, call signalsIsland with the island's name, the component and its props:

/** @jsxImportSource @bext-stack/framework */
import { signalsIsland } from "@bext-stack/framework/signals";
import Counter from "../components/Counter";

export async function loader() {
  return { start: 3 };
}

export default function Page({ data }: { data: { start: number } }) {
  return (
    <main>
      <h1>Demo</h1>
      {signalsIsland("Counter", Counter, { initial: data.start })}
    </main>
  );
}

signalsIsland(name, Component, props?, opts?) runs the component on the server and returns its HTML inside a <bext-island> element, with the props serialized as JSON. The result drops into JSX as is; <Raw html={...} /> works too.

There is nothing else to wire up. When a page's HTML contains a <bext-island>, the runtime adds a small loader that requests /islands/<name>.js for each island on the page. That script is compiled by the server from your file, with no build step. In the browser, it runs the component again with the same props, then attaches the signals, reactive attributes and event handlers to the HTML already on screen, without rebuilding it.

Props from the server

The props passed to signalsIsland are serialized with JSON.stringify and written to the island's data-props attribute. The browser reads them back to run the component.

  • Pass JSON data only: strings, numbers, booleans, null, arrays and plain objects. A function or an undefined value is dropped, and a Date arrives as a string.
  • Props are visible in the page source. Do not put secrets or anything the visitor must not see in them.
  • Load data in the page's loader and pass the result as props: an island component is synchronous and cannot await a promise.

Signals API

import {
  signal, computed, effect, batch, untracked, isSignal,
  List, Show, Switch, Match,
} from "@bext-stack/framework/signals";
Function Description
signal(initial) Creates a reactive value. .value reads or writes it, .peek() reads it without subscribing.
computed(fn) Derived value, recomputed on read when one of its dependencies changed.
effect(fn) Runs fn right away, then whenever a signal it read changes. fn may return a cleanup function. Returns a function that stops the effect.
batch(fn) Groups writes: each affected effect runs once, at the end.
untracked(fn) Reads signals inside fn without subscribing to them.
isSignal(v) true for a signal or a computed.

A write to .value only notifies when the new value is different (Object.is comparison).

What is reactive in JSX

In a "use signals" file, a JSX expression that reads .value from a signal declared in that file with const x = signal(...) or const x = computed(...) is made reactive automatically: {count.value}, {items.value.length} or value={state.value.q} update on their own.

Anywhere else, make reactivity explicit by passing the signal itself or a function:

<span>{() => props.label + ": " + total.value}</span>
<input disabled={() => saving.value} />

Handlers are written onClick, onInput, onSubmit… and are attached with addEventListener on the lowercase event name (click, input, submit).

Lists and conditions

<List each={todos} key={(t) => t.id}>
  {(t) => <li>{() => t.title}</li>}
</List>

<Show when={() => !loading.value} fallback={() => <p>Loading…</p>}>
  {() => <p>{results.value.length} results</p>}
</Show>

<Switch fallback={() => <p>No results</p>}>
  <Match when={error}>{() => <p>Error</p>}</Match>
  <Match when={() => results.value.length > 0}>{() => <ResultList />}</Match>
</Switch>
  • <List> takes an array signal (or a function returning an array) and a render function per item. With key, only items with a new key are rendered; items whose key was already there keep their node (focus and scroll position included) and are not redone, even if their data changed. Without key, the whole list is rebuilt on every change.
  • <Show> renders its content when when is truthy, otherwise fallback. The content is a function, run again on every flip.
  • <Switch> renders the first <Match> whose when is truthy, otherwise fallback.

Deferred hydration

By default, an island becomes interactive as soon as its script loads. The fourth argument to signalsIsland defers it:

{signalsIsland("Counter", Counter, { initial: 2 }, { lazy: "visible" })}
lazy The island is hydrated…
absent as soon as its script loads
"visible" when it comes near the viewport (200 px ahead)
"idle" when the browser is idle (requestIdleCallback)
"interaction" on the first pointerdown, focusin, keydown or touchstart on the island

The server-rendered HTML stays on screen while it waits: only interactivity is delayed. Without IntersectionObserver for "visible", or without requestIdleCallback for "idle", the island is hydrated right away. An unknown value is ignored.

Hand-written scripts in public/islands/

For behavior that needs no client-side JSX (a cart drawer, a search box, an enhanced form), a plain JavaScript file is often enough. Put it in public/islands/ and load it from the page:

<script src="/islands/cart.js?v=6" defer></script>

The server renders the full HTML; the script only adds behavior. These files are served as is: no compilation, no TypeScript, no npm package imports.

Caching and versions

  • "use signals" island: /islands/<name>.js is served with Cache-Control: no-cache. The browser checks for a new version on every load: nothing to version.
  • File in public/islands/: served by default with revalidation (max-age=0, must-revalidate and an ETag). These files are not watched by automatic reload: to force a script to reload, add a ?v= parameter to its URL and bump it on every change.

Keep each script's version in one place, for example a small function that builds the URL:

// src/lib/islands.ts
const VERSIONS: Record<string, number> = { cart: 6, search: 3 };

export function islandSrc(name: string): string {
  return "/islands/" + name + ".js?v=" + (VERSIONS[name] ?? 1);
}

Gotchas

Warning

The component runs twice: on the server to produce the HTML, then in the browser to rewire reactivity. Both runs must take the same path. A render that depends on Math.random(), the current time or window produces markers that no longer line up, and the affected bindings stay inert, with no error.

  • effect runs on the server too, as soon as it is created. Put browser-only code (fetch to your API, localStorage, timers) in event handlers, or guard it with typeof window !== "undefined".
  • Strings are not escaped: inside an island, a string JSX child is inserted into the HTML as is. A reactive value ({() => user.name}) is escaped. Go through a function for any text a user typed.
  • Replace, do not mutate: state.value.items.push(x) notifies nobody. Write a new value: state.value = { ...state.value, items: [...state.value.items, x] }.
  • The directive must open the file: no comment or import before "use signals", or the file is not recognized as an island.
  • Default export required: a component exported only by name does not hydrate.
  • The component returns JSX: no computed(() => <div />) as the return value. Put reactivity in the tree, with <Show>, <List> or functions.
  • One name per island: two island files must not share a name, and a public/islands/<name>.js file is served instead of the compiled island of the same name.
  • Module shared between islands: in a helper file imported by several islands, import the primitives from @bext-stack/framework/signals/core.

Where to next

Page What it covers
SDK Overview The component model and site layout
Routing & Rendering The src/app/ route tree, ISR/SSR, streaming
Loaders & Actions Loading the data you pass as props, handling forms
Deploy a PRISM Site watch_dirs, automatic reload and public/islands