Core Concepts

How Signet.js Works

The mental models behind signals, scopes, auto-unwrap, scouring, and cleanup.

Signals and Reactivity

What is a Signal?

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"

Computed Signals

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)

Why Signals Instead of VDOM?

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.

Batching Updates

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';
});

Reactive Scope

From Plain Object to Reactive Scope

When you pass a plain object to createApp(), Signet transforms it into a reactive scope in three passes:

  1. Plain values โ†’ wrapped in signal(). Pre-existing signals are stored as-is (no double-wrapping).
  2. Getter properties โ†’ wrapped in computed() and bound to an auto-unwrapping proxy.
  3. Methods โ†’ bound to a read+write proxy so this.prop reads and this.prop = x writes the underlying signal.

Scope Helpers

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.

Prototype Chain Inheritance

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.

Note: Always use for...in when enumerating scope keys โ€” Object.keys() does not traverse the prototype chain and will miss inherited properties.

Auto-Unwrap

Scope variables that are signals are automatically unwrapped when accessed in directive expressions. You write count, not count.value. This applies in three contexts:

1. In directive expressions

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" />

2. In computed getters

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; },
});

3. In methods

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 = '';
  },
});

Assignment and postfix in expressions

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>

Expression Evaluator

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
Literals42, 3.14, 'hello', true, null, undefined
Property accessobj.prop, items[0], user.profile.name
Arithmetica + b, x * y, n % 2
Comparisona === b, x !== y, n > 0, i <= 5
Logicala && b, x || y, !flag
Ternarycond ? 'yes' : 'no'
Assignmentcount = 5, name = 'Alice'
Postfixcount++, index--
Function callsfn(), submit($event), add(a, b)
Parenthesised(a + b) * c

Developer Mode

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');

Scouring (Zero Footprint)

After Signet processes each element, it removes all js-* attributes from the DOM. This "scouring" step has two benefits:

<!-- 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>

Cleanup and Lifecycle

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();

js-ref and cleanup

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.

Next Steps