Signals
Signals are bext's fine-grained reactivity runtime. They sit alongside
the React-based "use client" islands as a second hydration model:
no React in the bundle, no virtual DOM, no re-render of the initial
markup. Server emits HTML with paired marker comments around reactive
expressions; client walks the markers, attaches subscriptions to the
existing DOM nodes.
A signals counter ships about 2-3 KB of hydration runtime plus the component code, against React's ~45 KB minimum. Click → signal write → only the bound text or attribute updates; nothing else moves.
When to use signals vs React islands
"use client" (React) |
"use signals" (bext) |
|
|---|---|---|
| Bundle size | ~45 KB minimum (react + react-dom) | ~3 KB runtime + component |
| Hydration model | re-render with hydrateRoot; reconcile against server DOM |
walk markers, attach subs to existing DOM |
| Mental model | components return VDOM; framework diffs | functions create signals; reads track them |
| Ecosystem | full React ecosystem (hooks, libs, devtools) | inside-the-framework primitives only |
| Best for | feature-rich SPAs, react libs, devtool integration | small interactive widgets, performance-sensitive renders |
Signals shine on counters, toggles, simple lists, anything where the React payload is overkill and DOM stability matters (focus, scroll, animations, third-party iframes).
Quickstart
src/components/Counter.tsx:
"use signals";
/** @jsxImportSource @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: {count.value} (×2 = {doubled.value})</p>
<button onClick={() => { count.value++; }}>+1</button>
<button onClick={() => { count.value--; }}>-1</button>
</div>
);
}
The two prologue lines are the entire opt-in:
"use signals"directive — bext's discover-step picks this up and routes the component through the signals build pipeline rather than React. -@jsxImportSource @bext-stack/framework/signalspragma — tsc-rs resolves JSX calls against the signals adapter, which emits resumability markers and per-handler indexes instead of React elements.
Mount it on a PRISM page:
export default function Page() {
return (
<section>
<h1>Hello</h1>
{signalsIsland("Counter", Counter, { initial: 7 })}
</section>
);
}
That's it. Server renders the counter to HTML with markers; the
build pipeline emits a per-island bundle; the page-level loader
script picks it up by data-runtime="signals" and attaches
reactivity.
Primitives
signal(initial)
Writable cell. Reading .value inside an effect/computed subscribes;
writing notifies subscribers.
const count = signal(0);
count.value; // 0 — subscribes if called inside effect/computed
count.value = 1; // notifies subscribers
count.peek(); // 1 — read without subscribing
set is short-circuited by Object.is: writing the same value is
a no-op.
computed(fn)
Lazy, cached derived signal. Re-runs only when an input changed AND
something reads its .value.
const a = signal(2);
const b = signal(3);
const sum = computed(() => a.value + b.value);
sum.value; // 5 — subscribes, fn runs once
a.value = 10;
sum.value; // 13 — fn runs again on first read
Computeds memoize: reading .value repeatedly between input changes
returns the cached result without re-running the function.
effect(fn)
Run fn and re-run it whenever any signal it read changes. Returns
a dispose function.
const c = signal(0);
const stop = effect(() => {
console.log("count is", c.value);
});
c.value = 1; // logs "count is 1"
stop(); // detach
c.value = 2; // no log
If fn returns a function, that's a cleanup that runs before
the next re-run and on dispose. Useful for addEventListener /
setInterval / subscriptions.
batch(fn)
Defers all effect updates until fn returns. Each affected effect
runs at most once per batch.
batch(() => {
a.value = 10;
b.value = 20;
}); // effects that depend on a OR b run once, after the batch
untracked(fn)
Read inside the body without subscribing.
effect(() => {
const c = a.value; // subscribed
const b = untracked(() => b.value); // NOT subscribed
});
Branch-pruning subscriptions
Conditional reads automatically prune. If an effect reads a.value
on one run and b.value on the next, it stays subscribed only to
what it actually read on the most recent run — no leaks, no
over-firing.
const cond = signal(true);
effect(() => {
if (cond.value) console.log(a.value); // sub: cond, a
else console.log(b.value); // sub: cond, b — `a` dropped
});
JSX adapter
The signals JSX adapter is per-file via the @jsxImportSource
pragma. Inside a signals component:
- Reactive text:
{count}works (the adapter detects signal values),{count.value}also works (the compile pass auto-wraps it — see below),{() => count.value * 2}always works. - Reactive attributes:<div className={cls}>whereclsis a signal. Same with thunks:<div className={() => active.value ? "on" : "off"}>. - Event handlers:onClick={() => count.value++}etc. Handlers are indexed and emitted into HTML asdata-bs-onclick="N"; the hydrator bindshandlers[N]after walking the DOM.
The auto-wrap compile pass
bext-core's compile pipeline includes a Rust pass that walks JSX
expression containers in "use signals" files and wraps any
expression containing a <sig>.value read in (() => EXPR). So
this:
<p>Count: {count.value > 0 ? "yes" : "no"}</p>
becomes (before tsc-rs sees it):
<p>Count: {(() => count.value > 0 ? "yes" : "no")}</p>
The thunk runs inside the hydrator's effect, so the conditional
re-evaluates whenever count changes.
The pass is conservative:
- Only files starting with "use signals" are touched.
- Only JSX expression-container bodies are wrapped (not function
bodies — event handlers stay non-reactive).
- Only expressions that contain
<knownSig>.valuereads are wrapped;<p>{1 + 2}</p>and<p>{user.name}</p>(whenuserisn't a signal) pass through unchanged. - Assignments (count.value = 5) on the LHS are never wrapped.
Disable per-environment with BEXT_PRISM_COMPILE=0.
`` — reactive arrays
const items = signal(["a", "b", "c"]);
<li>{item}</li>}
/>;
Server emits paired list-marker comments around the rendered items. On signal change, the hydrator splices new items into the DOM range.
Keyed reconciliation
Add key for identity preservation across reorders, inserts, and
removals — items keep their DOM (so focus, scroll, <input> value,
animations all survive):
t.id}
render={(t) => <li><input value={t.text} /></li>}
/>
When todos changes, the hydrator computes the longest increasing
subsequence of new positions in the old list. Items in the LIS keep
their DOM untouched (zero insertBefore calls); only out-of-order
items get moved. Insertions render new DOM in fresh per-item
contexts; removals dispose subscriptions and drop nodes.
Keys are sanitized to [A-Za-z0-9_:.] for HTML-comment safety; -,
/, and other characters become _. Pick keys mindful of this —
typically a numeric id or a uuid is fine.
Memory model
Items in an unkeyed list run in the outer render context. Items in a keyed list each get their own per-item subcontext, so handler IDs scope per-item — no collisions across items. Disposers cascade: when the outer island is disposed, every nested-list item's subscriptions go with it.
Nested `` is supported: keyed-in-keyed, unkeyed-in-keyed, and keyed-in-unkeyed all work. The per-item attachment walk recurses into list bindings within the item's DOM range.
Hydration model
Server render emits HTML with three kinds of markers:
| Marker | Purpose |
|---|---|
<!--bsN-->...<!--/bsN--> |
reactive text — opens around the initial value |
<!--bsListN-->...<!--/bsListN--> |
list range — wraps all items |
<!--bsI{listId}:{key}-->...<!--/bsI{listId}:{key}--> |
per-item range (keyed lists only) |
<el data-bs-attrN="<name>" <name>="initial"> |
reactive attribute |
<el data-bs-on<event>="N"> |
event handler index |
After hydration, every data-bs-* attribute is removed and every
marker comment is consumed. The DOM ends up clean.
Client hydrateSignalsIsland(root, Component, props):
- Re-runs
Component(props)to rebuild the reactive graph and collect handlers + bindings. IDs are allocated in the same order the server saw, so they line up. 2. For eachdata-bs-on<event>="N"element → adds the captured handler. 3. For eachdata-bs-attrNelement → installs an effect that updates the attribute when its bound signal changes. 4. For each<!--bsN-->...<!--/bsN-->pair → swaps in a freshTextnode bound via effect to the signal. 5. For each list binding → installs the keyed or unkeyed reconciler.
The component runs once per mount, not per render. Subsequent DOM updates flow through individual effects, not through a re-run of the component body.
Build pipeline
The "use signals" discover step is wired in three places:
- framework/src/build.ts (production __fetch bundle).
- framework/src/serve.ts (dev Bun server — parity with build).
crates/bext-server/src/ssr_pipeline/prism.rs(PRISM-native Rust dispatcher used by bext-server).
All three discover signals islands, generate per-island client
entries, and inject a runtime-aware loader script into pages
containing <bext-island data-runtime="signals"> elements.
Streaming uploads (multipart)
The signals stack composes with bext's server actions,
including the streaming-multipart path: file uploads above the spool
threshold (~1 MB by default) write to a /tmp/bext-stream-* tempfile
and the action's parser walks chunks via the __bextReadChunk V8
bridge native. See the server-actions guide for details.
Comparison vs Marko / Solid / Qwik
The fine-grained-reactivity space has good company. bext's signals runtime is closest to Solid in spirit (signals + reactive DOM, no VDOM) and to Marko in mechanism (compile-time markup markers, lazy hydration). Differences worth knowing:
- Versus Solid: bext doesn't ship a Solid-style
createSignalhook API — primitives are direct functions. The hydration model is resumption-from-server-DOM (no client-side template compilation), which keeps the runtime smaller but means we don't support arbitrary expression types in templates today (noShow,Switch,Match; conditionals via JSX expression children only). - Versus Marko: Marko is a full top-down compiler; bext's compile pass is additive — your code is still TSX, the auto-wrap is the only mandatory transform. Trade-off: Marko's compiler has much more aggressive static partitioning available. - Versus Qwik: bext doesn't lazy-load handlers — they're in the per-island bundle, eagerly evaluated on hydrate. Resumption is about not re-rendering the markup, not about deferring code shipment. For lazy code, use the React island path withnext/dynamicor your ownimport().
API reference
Module: @bext-stack/framework/signals.
function signal(initial: T): Signal;
function computed(fn: () => T): Signal;
function effect(fn: () => void | Cleanup): () => void;
function batch(fn: () => T): T;
function untracked(fn: () => T): T;
function isSignal(v: unknown): v is Signal<unknown>;
interface Signal {
value: T; // get + set
peek(): T; // read without subscribing
}
function signalsIsland(
name: string,
Component: (props: any) => string,
props?: any,
): string;
function List(props: {
each: Signal<T[]> | (() => T[]);
render?: (item: T, i: number) => string;
children?: (item: T, i: number) => string;
key?: (item: T, i: number) => string | number;
}): string;
Module: @bext-stack/framework/signals/hydrate (client-only).
function hydrateSignalsIsland(
root: HTMLElement,
Component: (props: any) => string,
props?: any,
options?: { strict?: boolean },
): { dispose: () => void };
Status
- ✅ Server render with markers + per-handler indexes.
- ✅ Hydrator with text, attribute, event, list (keyed + unkeyed),
nested-list, and dispose cascade.
- ✅ LIS-based reconciliation for keyed lists.
- ✅ Compile pass auto-wraps
<sig>.valueinside JSX containers. - ✅ Build/serve/PRISM-native pipelines all discover"use signals". - ✅ /signals demo route inprism-demoexercises the full path. - 🚧 NoShow/Switch/Matchhelpers (use JSX conditional expressions). - 🚧 No Solid-styleResourcefor async data (use bext's `` at the page level instead).