Skip to content

Reactivity

kerf’s reactivity primitive is @preact/signals-core, a thin re-export. The whole API is four functions and two types.

import { signal } from "kerfjs";
const count = signal(0);
count.value; // → 0 (read)
count.value = 7; // (write — notifies subscribers)

A signal is a single piece of reactive state. Reads via .value are tracked when they happen inside an effect() or computed(). Writes via .value = … trigger every effect that read this signal during its previous run.

import { computed, signal } from "kerfjs";
const a = signal(1);
const b = signal(2);
const sum = computed(() => a.value + b.value);
sum.value; // → 3
a.value = 10;
sum.value; // → 12

A computed is a derived signal. Its body is re-run whenever any signal it reads changes. The result is cached until a dependency mutates.

computed is read via .value, just like signal. From a consumer’s perspective, you can’t tell whether a value is a raw signal or a computed — which is the point.

import { effect, signal } from "kerfjs";
const count = signal(0);
const dispose = effect(() => {
console.log("count is", count.value);
});
// → "count is 0" (synchronous initial run)
count.value = 1;
// → "count is 1"
dispose();
count.value = 2;
// (nothing logged — effect is disposed)

An effect() runs its body synchronously once on creation, then re-runs it whenever any signal read during the last run changes. Returns a disposer that tears the effect down.

The body may itself return a cleanup function: it runs right before the next re-run and again on dispose — the place to clear a timer, abort a fetch, or remove a listener the body installed:

const dispose = effect(() => {
const id = setInterval(() => console.log(count.value), 1000);
return () => clearInterval(id); // runs before each re-run and on dispose()
});

mount() is built on effect() — same semantics, with kerf’s segment-aware diff as the side effect.

import { batch, effect, signal } from "kerfjs";
const a = signal(1);
const b = signal(2);
effect(() => console.log(a.value + b.value));
// → "3"
batch(() => {
a.value = 10;
b.value = 20;
});
// → "30" (one log, not two)

Coalesces multiple writes inside fn into a single re-run of any subscribed effect / computed. Useful when an action mutates several signals atomically and you don’t want consumers to see intermediate states.

import type { ReadonlySignal, Signal } from "kerfjs";
function reset(s: Signal<number>) {
s.value = 0; // OK — Signal allows writes
}
function display(s: ReadonlySignal<number>) {
return s.value; // OK — read-only
// s.value = 0; // type error — ReadonlySignal forbids writes
}

Both types also carry peek(), which returns the current value without subscribing: reading s.peek() inside a computed(), effect(), or mount() render does not make it re-run when s changes. Use it for a value you need once (a starting point, a log line) where a .value read would add an unwanted dependency.

computed() returns ReadonlySignal<T>. signal() returns Signal<T>. Stores expose state: ReadonlySignal<TState> so consumers can’t bypass the action layer.

arraySignal(initial) (granular collection signal)

Section titled “arraySignal(initial) (granular collection signal)”
import { arraySignal } from "kerfjs/array-signal";
const rows = arraySignal<{ id: number; label: string }>([]);
rows.push({ id: 1, label: "a" });
rows.update(0, (r) => ({ ...r, label: "A" }));
rows.insert(1, { id: 2, label: "b" });
rows.move(0, 1);
rows.remove(0);
rows.replace([{ id: 99, label: "reset" }]);
rows.value; // → the live array, readonly-typed; registers a tracking dependency

arraySignal is a keyed-list-friendly variant of signal(). The mutators emit typed patch events (update / insert / remove / move / replace); when an arraySignal is bound to each(...) inside a mount(), the keyed list reconciler applies just the patches against the live DOM — no per-row iteration, no classifyItems Map build, no LIS pass over unchanged rows. Cost is O(patches), not O(N).

It lives in its own subpath (kerfjs/array-signal) so apps that don’t need granular collections shed ~1 KB from the main barrel. The class itself is detected via a brand symbol — not instanceof — so multiple bundle copies still interoperate.

Read-side semantics match a regular signal: reads of arraySig.value inside effect() / computed() register as dependencies, so computed(() => arraySig.value.filter(...)) works the way you expect. Note the returned array is the signal’s live internal array typed readonly, not a defensive copy — a reference you hold across a later mutation will observe that mutation; spread ([...arraySig.value]) if you need a stable snapshot.

  • Long keyed lists (hundreds of rows) where most updates are pointwise (selection class flips, single-row edits, append-to-end, etc.).
  • Lists where signal(items.value = [...items.value, x]) is the bottleneck — that pattern triggers a full classify pass on every render.
  • Short lists (a handful of items). The constant-factor wins don’t outweigh the API friction.
  • Lists where every render rebuilds from scratch (filter / sort pipelines that reset on every input change). Use signal + computed and let each()’s identity-based caching handle the rest.
  • arraySignal mutates _items eagerly at the call site. The patch queue and the snapshot are always in sync after a mutation returns.
  • Every update / insert / remove / move index must be a finite integer in range. Invalid indices throw before the callback, source array, or patch queue changes; equal move(from, to) indices are validated before they no-op.
  • Multiple each(...) callsites bound to the same arraySignal in one render: the first caller drains the patch queue and runs granular reconcile; the second (and beyond) sees an empty queue and falls through to the snapshot path. Both lists end up correct, but only one gets the perf win. Prefer one-binding-per-arraySignal-per-render.
  • A replace() patch in a batch forces the snapshot path for that render. Granular optimizations resume the next render.
  • A throwing row render falls back to the snapshot path automatically. If the snapshot also throws on the same bad row, the error bubbles to the user — fix the row in the signal, and the next render rebuilds from scratch.
  • They are not deep-reactive. Mutating an array or object inside signal.value does NOT trigger subscribers. Always assign a new value:
    // wrong — silently doesn't notify
    count.value.push(1);
    // right
    count.value = [...count.value, 1];
  • They don’t track property accesses on plain objects — just .value on signal/computed instances.
  • They are not async. There’s no scheduling, no concurrent mode. Effects run synchronously when their dependencies write.
  • One consumer reads it = signal. Local UI state (this dialog’s open/closed, this counter’s value, this slider’s position) belongs in a signal scoped to the component that owns it.
  • Two+ consumers / multi-step mutations / cross-route lifetime = store. See Stores.

A signal can reach JSX two ways, and the difference is the single most important idiom choice in kerf:

  • Pass the signal itself ({count}, class={sig}) — kerf binds that one hole to the signal, so a change updates only that attribute or text node. render() does not re-run, and each()’s reconciler does not walk. This is the canonical form for a value hole: whenever a hole’s content is “this signal’s (or computed’s) current value,” pass the signal.
  • Read .value ({count.value}, cond ? <a/> : <b/>) — the read is tracked by mount()’s effect, so a change re-runs the whole render function and kerf applies the smallest DOM cut. This is the tool for structural changes: conditionals that swap elements, list shape, anything where what exists — not just a value — depends on the signal.

Rule of thumb: values bind, structure re-renders. The bound form is both the fastest path kerf has and the one with the simplest cost model (one effect, one node write), so reach for it first; fall back to .value when the JSX structure itself depends on the signal. To find .value holes worth migrating, the opt-in dev warning — enableWarnings({ valueOnlyRerender: true }) from kerfjs/dev, or KERF_DEV_WARN_VALUE_ONLY_RERENDER=1 under Node — flags re-renders whose only differences were text/attribute values (see the dev-warnings doc).

The idiom’s logical endpoint: a fully bound mount never re-renders at all. A render function that reads no .value registers zero dependencies on mount()’s wrapped effect, so it runs exactly once — every subsequent update flows through the per-hole binding effects, with no string rebuild, no byte-compare, and no morph. There’s no flag to set; it falls out of the dependency tracking.

The classic bound-value case — a selectedId flipping one row’s class:

const selectedId = signal<number | null>(null);
mount(root, () => (
<ul>
{each(
items.value,
(item) => (
// Pass the computed itself — kerf binds `class` to it.
<li
class={computed(() =>
item.id === selectedId.value ? "selected" : "",
)}
>
{item.label}
</li>
),
(item) => item.id,
)}
</ul>
));
selectedId.value = 3; // updates only the 2 affected <li> class attrs — no re-render, no reconcile

Text holes work the same way:

const count = signal(0);
mount(root, () => <span>{count}</span>); // pass the signal, not count.value
count.value++; // updates only that text node

A bound text hole does not need its own element — it can share a parent with static text and other holes, and the mix survives coarse re-renders intact:

const elapsed = computed(() => fmt(playhead.value));
mount(root, () => (
<div class="time">
{elapsed} / {fmt(duration)}
</div>
));
  • Any hole whose content is a signal’s or computed’s current value. Counters, labels, status attributes, selection classes, ticking values. Derived text composes with computed: {computed(() => \${n.value} items`)}`.
  • It scales down gracefully: a binding on a rarely-changing value costs one idle effect — there is no cliff that makes binding “wrong” for cold holes.
  • The JSX structure depends on the signal — cond ? <EditForm/> : <Summary/>, showing/hiding subtrees, choosing which list to render. A binding can only write one attribute or text node; structure needs the render fn.
  • Truly static one-shot values — just write class={cond ? 'a' : 'b'} (a plain string) when nothing reactive drives the hole.
  • Whole-list structural changes (add/remove/move rows) — that’s arraySignal’s job, not a per-attribute binding.

Wrap the expression in computed(() => …) (or pass a plain signal). The memoization matters: when a shared signal like selectedId changes, every row’s computed re-evaluates cheaply, but only the ones whose value actually changed re-run their bound effect — so a selection flip touches ~2 DOM nodes, not N. (kerf has no compiler, so it can’t auto-lift a bare {expr} into a tracked closure the way Solid does — the signal/computed must be explicit.) For a large list, read the selection through selector() (selector(source) below) so a change re-evaluates two rows instead of all N.

The JSX runtime renders to HTML strings. When it sees a signal in a hole it emits a marker into the string instead of stringifying, records the binding, and — after the one innerHTML parse — wires a tiny effect per marker that writes straight to the node. So the string-render model is preserved:

  • SSR / SafeHtml.toString(): outside a mount() there’s nothing to wire, so a bound signal snapshots its current value and emits no markers — server output is correct and marker-free.
  • Bound URL attributes (href/src/formaction/action/xlink:href/data) get the same dangerous-URL screening as static attributes — including mixed-case attribute-name spellings and control-character-obfuscated schemes. A bound value resolving to javascript:/vbscript: or a script-executing data: document type (text/html, image/svg+xml, XHTML/XML) is never written. With kerfjs/dev installed the screen throws; without it the production shape warns and drops the attribute. kerf does not infer that choice from NODE_ENV; raw() opts out.

Because the wiring pass finds its markers by scanning the mounted subtree and matching by id, a small set of attribute and comment names is reserved for kerf — don’t put them on your own elements or emit them through raw():

  • the data-kfb and data-kfbrow attributes (fine-grained attribute bindings), and
  • HTML comments beginning kfb:, kfbr:, or kf-list: (text bindings and each() list boundaries).

A marker that collides with a real binding’s id is not contained to where it appears — the wiring pass scans the whole mounted subtree, so a stray marker can reach across it: a duplicate kfb:/kfbr: comment steals a sibling text binding’s update (the effect wires to the wrong node, silently), and a duplicate kf-list: comment can bind a real each() list to the wrong parent element so its rows render into the wrong place. These names are an internal detail you’ll never need in normal use; the only way to hit this is to hand-write one of them or emit one through raw() (e.g. rendering user-authored HTML that happens to contain one — sanitize such HTML upstream, as always).

Bind a stable signal source; don’t switch which signal instance you bind. Wrapping in computed(() => …) creates a fresh instance every render — that’s fine, because the live effect stays bound to the first one and it reads the same underlying signals, so later changes still fire. What you must not do is bind a different signal instance across renders (class={cond ? sigA : sigB}): on a re-render that leaves the surrounds unchanged, kerf keeps the original effect and doesn’t re-bind, so the hole goes stale. Bind one computed that switches internally instead (class={computed(() => cond.value ? sigA.value : sigB.value)}).

Because this failure is silent (the UI just stops updating, with no error), there’s an opt-in dev warning for it. Install kerfjs/dev, then switch on staleBinding with enableWarnings({ staleBinding: true }) (browser) or KERF_DEV_WARN_STALE_BINDING=1 (Node/SSR/CI); kerf will console.warn once, naming the hole, the first time a static-surround hole binds a different signal instance on that fast path. It is off by default, and omitting the dev entry makes the warning unreachable in a production bundle. Note it compares signal identity, so it will also flag a fresh inline computed(() => …) on a global hole (safe, but a new instance each render) — bind a stable computed/signal reference for global holes to keep it quiet.

Row bindings that depend on the row’s own data work across in-place updates. A row binding’s effect closes over the row object as it was when the row rendered. When an arraySignal update (or a cacheKey-driven re-render) applies to a row in place — the DOM node survives — kerf compares the fresh render’s binding instances against the wired ones per hole: unchanged instances carry forward for free (stable external signals, cache hits), and any changed instance is re-wired against the surviving node. So {computed(() => item.label)} inside an arraySignal row updates correctly after update() swaps the row object; a bound hole is never left reading the pre-update object.

selector(source) — keyed equality at O(1)

Section titled “selector(source) — keyed equality at O(1)”

The selection pattern above, computed(() => row.id === selectedId.value ? 'sel' : ''), subscribes every row’s computed to selectedId. A selection change touches two rows’ DOM, but it notifies and re-evaluates all N computeds. selector() moves that work out of the dependency graph:

import { computed, selector, signal } from 'kerfjs';
const selectedId = signal<number | null>(null);
const isSelected = selector(selectedId);
// in a row:
<tr class={computed(() => (isSelected(row.id).value ? 'danger' : ''))}>…</tr>

selector(source) subscribes to source once and hands out one boolean signal per key. A source change writes only the previous key’s signal (to false) and the next key’s (to true), so one selection costs O(1) row work regardless of list length. It is plain runtime code over signals; no compiler is involved.

  • Entries live as long as their subscribers. A key’s signal is registered when its first subscriber arrives and dropped when its last one goes. Removing a row disposes its binding effects, which releases its key, so create/clear cycles don’t accumulate entries. The selector’s subscription to source exists only while some key is watched.
  • A key signal is always current, subscribed or not. Reading one you hold (in a click handler, say) gives the current comparison: while nothing subscribes, it reads the source lazily, and while something does, it follows the selector’s flip. Signals handed out outside a subscriber are not cached, so each isSelected(k) call there returns a fresh one.
  • Keys compare like Map keys (SameValueZero), so use primitives or object references. There is no custom equality: a comparator would have to visit every key, which is the O(N) cost this avoids.
  • Use it where one signal selects among many rows. For a handful of rows the plain computed is just as good and needs no extra concept.

setSelector(source) — membership in a set

Section titled “setSelector(source) — membership in a set”

selector() answers “is this key the selected one?”. For “is this key in the set?” (multi-select, a tree’s expanded rows, fields with errors), use setSelector() over a signal holding a ReadonlySet:

import { computed, setSelector, signal } from 'kerfjs';
const checked = signal<ReadonlySet<string>>(new Set());
const isChecked = setSelector(checked);
<tr class={computed(() => (isChecked(row.id).value ? 'on' : ''))}>…</tr>

Each write diffs the previous set against the next, O(|old| + |new|), and notifies only the keys that were added or removed. Assign a new Set; a new Set with the same members notifies nothing. Per-key signals live and release exactly as selector()’s do. It pays off when the sets are small next to the number of rows; a set holding most keys costs about what a plain computed per row does.

Finding the fan-outs. With kerfjs/dev installed, enableWarnings({ selectorFanout: true }) warns when one signal write re-evaluates hundreds of computed()s and almost none of them change value — the shape both helpers replace — and prints where the write happened. See Dev-mode warnings.

For live data keyed by id (presence dots, ticker prices, job status from a websocket) where each message changes one or a few entities, keep one signal per entity in a Map, not one signal holding the whole Map:

import { signal, type Signal } from 'kerfjs';
const prices = new Map<string, Signal<number>>();
const priceOf = (id: string): Signal<number> => {
let s = prices.get(id);
if (!s) prices.set(id, (s = signal(0)));
return s;
};
socket.onmessage = ({ data }) => {
const { id, price, removed } = JSON.parse(data);
if (removed) prices.delete(id);
else priceOf(id).value = price; // O(1): only this row's bound hole updates
};
// in a row:
<td>{priceOf(row.id)}</td>

Replacing a signal<Map<K, V>> on every message costs O(N) twice: the producer clones N entries, and every row’s computed(() => map.value.get(row.id)) re-evaluates (about 0.6 ms each at 10k entries). A per-entity signal makes the update O(1) with no extra API. Delete an entity’s signal when the entity leaves; its rows’ bindings release it when they are removed. This answers “what is this key’s value?”, while selector() / setSelector() answer “is this key the chosen one?”.