Skip to content

Reactivity

kerf’s reactivity primitive is @preact/signals-core re-exported through src/reactive.ts. 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.

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.

2.5 The Signal<T> and ReadonlySignal<T> types

Section titled “2.5 The Signal<T> and ReadonlySignal<T> types”
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
}

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

2.6 arraySignal(initial) (granular collection signal)

Section titled “2.6 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; // → readonly snapshot, 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: arraySig.value is a snapshot, and reads inside effect() / computed() register as dependencies. So computed(() => arraySig.value.filter(...)) works the way you expect.

  • 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.
  • 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 §3 Stores.