Reactivity
kerf’s reactivity primitive is @preact/signals-core re-exported through src/reactive.ts. The whole API is four functions and two types.
2.1 signal(initialValue)
Section titled “2.1 signal(initialValue)”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.
2.2 computed(fn)
Section titled “2.2 computed(fn)”import { computed, signal } from 'kerfjs';
const a = signal(1);const b = signal(2);const sum = computed(() => a.value + b.value);
sum.value; // → 3a.value = 10;sum.value; // → 12A 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.
2.3 effect(fn)
Section titled “2.3 effect(fn)”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.
2.4 batch(fn)
Section titled “2.4 batch(fn)”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 dependencyarraySignal 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.
When to reach for arraySignal
Section titled “When to reach for arraySignal”- 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.
When NOT to reach for it
Section titled “When NOT to reach for it”- 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+computedand leteach()’s identity-based caching handle the rest.
Gotchas
Section titled “Gotchas”arraySignalmutates_itemseagerly at the call site. The patch queue and the snapshot are always in sync after a mutation returns.- Multiple
each(...)callsites bound to the samearraySignalin 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.
2.7 What signals are NOT
Section titled “2.7 What signals are NOT”- They are not deep-reactive. Mutating an array or object inside
signal.valuedoes NOT trigger subscribers. Always assign a new value:// wrong — silently doesn't notifycount.value.push(1);// rightcount.value = [...count.value, 1]; - They don’t track property accesses on plain objects — just
.valueon signal/computed instances. - They are not async. There’s no scheduling, no concurrent mode. Effects run synchronously when their dependencies write.
2.8 When to use raw signals vs. stores
Section titled “2.8 When to use raw signals vs. stores”- 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.