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 anundefinedvalue is dropped, and aDatearrives 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. Withkey, 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. Withoutkey, the whole list is rebuilt on every change.<Show>renders its content whenwhenis truthy, otherwisefallback. The content is a function, run again on every flip.<Switch>renders the first<Match>whosewhenis truthy, otherwisefallback.
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>.jsis served withCache-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-revalidateand anETag). 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
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.
effectruns on the server too, as soon as it is created. Put browser-only code (fetchto your API,localStorage, timers) in event handlers, or guard it withtypeof 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>.jsfile 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 |