How Reactivity Works
A picture and five questions for the chain that turns a count.value = x write into a repainted <span>. Read this once and the rest of the docs snap into place.
The signal-flow diagram
Section titled “The signal-flow diagram” ┌──────────────────────────────┐ │ signal.value = newValue │ └─────────────┬────────────────┘ ▼ ┌───────────────────┐ │ Object.is(old,new)?│ └────┬─────────┬────┘ equal│ │different ▼ ▼ (no-op) ┌─────────────────┐ │ notify subscribers │ └─┬──────┬──────┬─┘ │ │ │ ┌─────────┘ │ └────────────┐ ▼ ▼ ▼ ┌──────────────────┐ ┌──────────────┐ ┌────────────────────┐ │ effect re-runs │ │ computed │ │ getter (child or │ │ side effects │ │ marks stale │ │ prop) re-runs; │ │ fire │ │ → recomputes│ │ patches that one │ │ │ │ on next read│ │ DOM position only │ └──────────────────┘ └──────────────┘ └────────────────────┘No top-down re-render. Whisq doesn’t diff a virtual tree. Each subscriber is wired directly to the signals it read, and only those subscribers fire when their signals change. A keystroke that updates one signal patches one DOM position — the rest of the tree is untouched.
Five questions
Section titled “Five questions”1. What triggers tracking?
Section titled “1. What triggers tracking?”Reading .value (or calling an accessor like users.data()) inside a reactive scope registers a subscription. The signal stores a reference to the active subscriber; the subscriber stores a reference back to the signal so it can clean up.
Reading outside any reactive scope is just a value read — no subscription, no re-evaluation. Use signal.peek() to read inside a reactive scope without registering as a dependency.
2. What’s a “reactive scope”?
Section titled “2. What’s a “reactive scope”?”The currently-running effect, computed body, or getter passed as a child / prop. Whisq maintains an active-subscriber stack while these run; tracked reads add the signal → current subscriber link.
The four positions that establish reactive scope:
| Position | Form | Notes |
|---|---|---|
| Effect body | effect(() => { ... }) | Re-runs when any read signal changes |
| Computed body | computed(() => ...) | Lazy — only re-runs on read after a dependency changed |
| Getter child | span(() => count.value) | Re-runs and patches the text node |
| Getter prop | div({ class: () => ... }) | Re-runs and patches that one prop |
Non-keyed each() render callbacks are builder functions invoked fresh on every source change — they run inside the same reactive scope as the each() itself, not in a per-item effect. Only getters you explicitly write inside the returned nodes establish reactive positions. (Keyed each() is different — see Question 5.)
3. What triggers re-execution?
Section titled “3. What triggers re-execution?”Only signals that changed, and only their subscribers. There’s no top-down render pass. Each subscriber re-runs once in response to its dependency change; if batch() is active, the re-runs queue and flush at the end of the batch.
4. When does batch() matter?
Section titled “4. When does batch() matter?”Multiple synchronous writes to different signals — without batch(), each write triggers its subscribers immediately. Subscribers that read more than one of the writes will run more times than necessary.
// ❌ effect runs twicefirstName.value = "Grace"; // effect runs (sees "Grace" + old lastName)lastName.value = "Hopper"; // effect runs again (sees "Grace" + "Hopper")
// ✅ effect runs oncebatch(() => { firstName.value = "Grace"; lastName.value = "Hopper";}); // effect runs once at flush (sees "Grace" + "Hopper")Single writes don’t need batch(). When an event handler writes to multiple signals, each write notifies its subscribers immediately — Whisq does not auto-batch event handlers. Wrap all the writes in batch() if you want them to flush as a group.
5. How does keyed each() fit?
Section titled “5. How does keyed each() fit?”The reconciler maintains per-entry signals (itemSig, indexSig). Replacing the source array doesn’t re-mount items with the same key — the reconciler writes the new item value into itemSig.value, which fires any reactive getter that closed over () => item().
┌──────────────────────────┐ │ todos.value = newArray │ └────────────┬─────────────┘ ▼ ┌──────────────────────────┐ │ each() reconciler runs: │ │ - same keys → reuse node │ │ - new keys → mount │ │ - missing → unmount │ └────────────┬─────────────┘ ▼ ┌──────────────────────────┐ │ for each reused entry: │ │ itemSig.value = newItem │ └────────────┬─────────────┘ ▼ ┌──────────────────────────┐ │ getters that closed over │ │ () => todo() see fresh │ │ values — DOM patches │ │ where they are read │ └──────────────────────────┘This is why () => todo().done stays live across array replacement — the getter re-reads todo(), which calls into the reconciler’s itemSig, which now holds the new value.
Component lifecycle
Section titled “Component lifecycle”Component setup runs once. Reactivity flows through the signals and getters you create in setup, not through re-running setup.
┌────────────────────────────┐ │ component(() => { ... }) │ │ called as Component({...}) │ └─────────────┬──────────────┘ ▼ ┌────────────────────────────┐ │ setup function runs ONCE │ │ - props captured │ │ - signals created │ │ - root element returned │ └─────────────┬──────────────┘ ▼ ┌────────────────────────────┐ │ mounted to DOM │ │ → onMount() callbacks fire│ └─────────────┬──────────────┘ ▼ ┌────────────────────────────┐ │ runtime: signal writes → │ │ targeted re-evaluation of │ │ effects/computeds/getters │ │ that read those signals │ │ (setup does NOT re-run) │ └─────────────┬──────────────┘ ▼ ┌────────────────────────────┐ │ unmounted │ │ → onCleanup() fires │ │ → effects auto-disposed │ └────────────────────────────┘The “setup runs once” rule is the source of two patterns elsewhere in the docs:
- Pass getters for changing prop values. A parent that wants the child to reflect a changing value passes
() => signal.value, notsignal.value. The child readsprops.value()inside a reactive position —propsis captured at setup, but the function call returns the current value. - Don’t create signals inside reactive callbacks. A
signal()call inside an effect body or element-callback body constructs a new signal every time the callback re-runs, dropping the old one’s subscribers. Hoist signal creation to setup or module scope.
See also
Section titled “See also”- Signals — the primitives the diagrams refer to.
/api/effect/— the effect API. Note:effect()runs once on creation, then re-runs when its tracked dependencies change./api/batch/— when to wrap multiple writes./api/each/— the keyed-each reconciler in practice.- Common Mistakes — what goes wrong when this model is applied incorrectly.
Docs current to v0.1.0-alpha.9 . All releases →