The mental models behind signals, scopes, auto-unwrap, scouring, and cleanup.
A signal is a reactive container for a value. Reading its .value property inside an effect registers a dependency. When the signal's value changes, all registered effects re-run automatically.
import { signal, effect } from 'signet.js';
const count = signal(0);
// This effect runs immediately, then re-runs whenever count changes
effect(() => {
console.log('Count is now:', count.value);
});
count.value = 1; // โ logs "Count is now: 1"
count.value = 5; // โ logs "Count is now: 5"
A computed signal derives its value from other signals. It is lazily evaluated and only re-computes when an upstream dependency changes.
import { signal, computed } from 'signet.js';
const count = signal(2);
const doubled = computed(() => count.value * 2);
console.log(doubled.value); // โ 4
count.value = 10;
console.log(doubled.value); // โ 20 (recomputed lazily)
Virtual DOM frameworks (React, Vue, Svelte in older versions) compare the previous and next state of the entire UI tree on every update. Signals are more surgical: each signal tracks exactly which effects depend on it. A change to one signal only re-runs the effects that read it โ there is no tree to diff.
Concretely: updating a count signal only touches the exact DOM nodes (text nodes, attributes) that depend on count. Nothing else runs.
Use $batch() (or the exported batch()) to group multiple signal writes so effects only re-run once after all writes complete.
// Without batch: two separate effect runs
count.value = 1;
name.value = 'Alice';
// With batch: one combined effect run
import { batch } from 'signet.js';
batch(() => {
count.value = 1;
name.value = 'Alice';
});
When you pass a plain object to createApp(), Signet transforms it into a reactive scope in three passes:
signal(). Pre-existing signals are stored as-is (no double-wrapping).computed() and bound to an auto-unwrapping proxy.this.prop reads and this.prop = x writes the underlying signal.Every scope exposes three helpers from @preact/signals-core for use directly in directive expressions:
| Helper | Description |
|---|---|
| $signal(v) | Create a new signal with initial value v. |
| $computed(fn) | Create a derived computed signal. |
| $batch(fn) | Group multiple signal writes for one effect run. |
Nested js-scope elements create child scopes via Object.create(parentScope). This means every property on the parent is readable from the child, and local properties shadow parent ones โ exactly like JavaScript prototype inheritance.
<div js-scope="{ step: 5, count: 0 }">
<!-- Nested scope: can read `step` from parent -->
<div js-scope="{ childCount: 0 }">
<!-- `step` comes from the parent scope -->
<button js-on:click="childCount = childCount + step">+step</button>
<span js-text="childCount"></span>
</div>
</div>
The global .store() object also sits on the prototype chain, so store properties are available in all scopes but can be overridden locally.
for...in when enumerating scope keys โ Object.keys() does not traverse the prototype chain and will miss inherited properties.
Scope variables that are signals are automatically unwrapped when accessed in directive expressions. You write count, not count.value. This applies in three contexts:
The expression evaluator checks the signal brand symbol and reads .value transparently, registering reactive dependencies so the surrounding effect tracks this signal.
<!-- count is a signal, but you write just `count` -->
<span js-text="count"></span>
<span js-text="count * 2"></span>
<input js-bind:disabled="count === 0" />
Inside getter functions, this is a read-only proxy that auto-unwraps signals. Reading this.count returns the plain value and registers it as a dependency of the computed.
createApp({
count: 0,
items: ['a', 'b', 'c'],
// `this.count` reads the signal value โ no .value needed
get doubled() { return this.count * 2; },
get isEmpty() { return this.items.length === 0; },
});
Inside method functions, this is a read+write proxy. Reading this.count returns the plain value; assigning this.count = x writes through to signal.value.
createApp({
count: 0,
name: '',
increment() {
this.count = this.count + 1; // read + write; no .value
},
reset() {
this.count = 0;
this.name = '';
},
});
Direct assignment and postfix operators in directive expressions also write through to the signal:
<!-- All of these write to the underlying signal -->
<button js-on:click="count++">++</button>
<button js-on:click="count--">--</button>
<button js-on:click="count = 0">Reset</button>
<button js-on:click="name = 'Alice'">Set name</button>
The default entry point uses a custom recursive descent parser โ no eval() or new Function(). This makes it safe to use under strict CSP headers. Expression tokens are cached for performance.
Supported expression syntax:
| Category | Examples |
|---|---|
| Literals | 42, 3.14, 'hello', true, null, undefined |
| Property access | obj.prop, items[0], user.profile.name |
| Arithmetic | a + b, x * y, n % 2 |
| Comparison | a === b, x !== y, n > 0, i <= 5 |
| Logical | a && b, x || y, !flag |
| Ternary | cond ? 'yes' : 'no' |
| Assignment | count = 5, name = 'Alice' |
| Postfix | count++, index-- |
| Function calls | fn(), submit($event), add(a, b) |
| Parenthesised | (a + b) * c |
Call setDevMode(true) to log warnings when property resolution fails (missing scope keys, null access). Useful while developing; should be disabled in production.
import { createApp, setDevMode } from 'signet.js';
setDevMode(true); // Enable warnings
createApp({ count: 0 }).mount('#app');
After Signet processes each element, it removes all js-* attributes from the DOM. This "scouring" step has two benefits:
js-if and js-for clone their template before removing the attribute, so cloned elements don't re-trigger the same directive.<!-- Before mounting -->
<span js-text="count" js-bind:class="count > 5 ? 'high' : ''"></span>
<!-- After mounting (what lives in the DOM) -->
<span class="high">7</span>
Every reactive directive registers a cleanup function when it creates an effect(). Cleanups are stored in a WeakMap<Element, Function[]> keyed by the DOM element.
Depth-first cleanup: When an element is removed (e.g., by a js-if becoming false), runCleanups(el) traverses its descendants first, then the element itself. This ensures inner effects dispose before their outer containers.
Unmounting: Call .unmount() on the returned app object to dispose all effects for the root element and its descendants.
const app = createApp({ count: 0 }).mount('#app');
// Later, to tear down all reactive effects:
app.unmount();
The js-ref directive assigns a DOM element to a named signal. On cleanup, the signal is set to null โ preventing dangling references to removed elements.