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/signals pragma — 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}> where cls is a signal. Same with thunks: <div className={() => active.value ? "on" : "off"}>. - Event handlers: onClick={() => count.value++} etc. Handlers are indexed and emitted into HTML as data-bs-onclick="N"; the hydrator binds handlers[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>.value reads are wrapped; <p>{1 + 2}</p> and <p>{user.name}</p> (when user isn'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):

  1. 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 each data-bs-on<event>="N" element → adds the captured handler. 3. For each data-bs-attrN element → installs an effect that updates the attribute when its bound signal changes. 4. For each <!--bsN-->...<!--/bsN--> pair → swaps in a fresh Text node 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 createSignal hook 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 (no Show, 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 with next/dynamic or your own import().

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>.value inside JSX containers. - ✅ Build/serve/PRISM-native pipelines all discover "use signals". - ✅ /signals demo route in prism-demo exercises the full path. - 🚧 No Show/Switch/Match helpers (use JSX conditional expressions). - 🚧 No Solid-style Resource for async data (use bext's `` at the page level instead).