In part 1 I claimed AngeLite updates "only what changes" without Zone.js or a virtual DOM. This post shows the mechanism, using the actual source: a signals module of about 136 lines and a runtime of about 750.
The reactive core: three primitives
| Function | What it is | Usage |
|---|---|---|
signal(value) |
a value you can change |
s() reads, s.set(x) writes, s.update(v => ...) transforms, s.peek() reads without subscribing |
computed(fn) |
a derived value, recalculated only when something it reads changes | total() |
effect(fn) |
code that runs now and whenever the signals it reads change | returns a stop function |
Here is the heart of it, trimmed from signals.js:
export function signal(value) {
let subs = null; // allocated lazily, only if someone reads
const read = () => {
if (active) track(subs || (subs = new Set()));
return value;
};
read.set = next => {
if (Object.is(next, value)) return; // no change, no notification
value = next;
if (subs) subs.forEach(n => n.notify());
};
read.update = fn => read.set(fn(value));
read.peek = () => value;
return read;
}
Three details are doing a lot of work:
-
activeis a module-level pointer to the effect currently running. Reading a signal while an effect is active subscribes that effect. That's the whole dependency-tracking trick. - Subscriber sets are allocated lazily, so a signal nobody reads costs almost nothing.
-
Object.isshort-circuits writes of the same value.
Batching in a microtask
An Effect re-runs not immediately but through a queue flushed in a microtask:
notify() {
if (this.queued) return;
this.queued = true;
queue.push(this);
if (!pending) {
pending = true;
queueMicrotask(flush);
}
}
So three set() calls in a row cause one re-run and one repaint. The flip side is documented behaviour you should know: right after a set() the DOM doesn't reflect the change yet. await Promise.resolve() (or flush() in tests) is enough to wait.
sequenceDiagram
participant U as Your code
participant S as signal
participant Q as microtask queue
participant E as view effect
participant D as DOM
U->>S: count.set(1)
S->>Q: notify(effect) (queued once)
U->>S: count.set(2)
S->>Q: already queued, skip
Note over U,Q: current task ends
Q->>E: flush()
E->>S: read count() (re-subscribes)
E->>D: write only values that changed
Templates compile to instructions, not to a virtual tree
A template is HTML with a few extensions:
| Syntax | What it does |
|---|---|
{{ expr }} |
interpolation, always text |
[prop]="expr" |
binds a DOM property, or an input() for components |
[class.x]="cond", [style.width.px]="n", [attr.x]="v"
|
class, style, attribute (null/false removes) |
(event)="statement" |
listens to an event |
[(ngModel)]="sig" |
two-way binding with a writable signal |
| `{{ x \ | pipe:arg }}` |
The compiler turns each template once into a blueprint: an HTML fragment plus a list of instructions ("this text node depends on this expression", "this element has this event"). Creating a view is then:
- clone the blueprint's fragment,
- walk it once applying the instructions,
- attach one lean effect per view that re-evaluates the view's bindings and writes to the DOM only when a value changed.
No virtual tree is built or diffed. Expressions are evaluated by a small interpreter of our own (no eval, no new Function): literals, arithmetic, comparison and logical operators, ternary, ??, ./?./[ ], arrays, and calls to component methods.
Events are delegated
Handlers don't create a listener and closure per element. Events are delegated to document and stored as properties on the elements, so a table of 10,000 rows carries no per-row listeners. That's a big part of why memory stays low (about 6.6 MB for 10,000 rows in the project's benchmark versus 11.8 MB for the next lowest).
Control flow and keyed lists
@if, @else if, @else, @for and @empty use the Angular 17+ syntax:
@for (t of todos(); track t.id) {
<li [class.done]="t.done" (click)="toggle(t)">{{ t.title }}</li>
} @empty {
<li>Nothing to do</li>
}
@for reuses DOM nodes according to the track key, so reordering or updating a list moves existing elements instead of recreating them. When a list keeps the same keys in the same order, a fast path skips reconciliation (that optimisation took the "update every 10th row" scenario from about 9.8 ms to 7.9 ms in one benchmark run). Inside @for you get $index, $count, $first, $last, $even and $odd. Always give a stable key: without track, the key is the item itself, which is fine for strings and numbers but not for objects you recreate.
Rules that follow from the model
-
Plain properties don't repaint.
this.name = 'Ada'notifies nobody. State you want reflected must be a signal:name = signal('Ada'). -
Treat lists and objects as immutable. Identity is compared, so mutate-in-place-and-reassign-the-same-array does nothing. Write
items.update(l => [...l, next]). -
Logic goes in the class. Expressions don't support arrow functions, object literals, assignments or
new. If an expression needs more than one operator, give it a name (a method or acomputed).
A limit worth knowing
There is one effect per view, not per binding. When a signal a view reads changes, every binding in that view is re-evaluated (but only written to the DOM if its value changed). For rows with few bindings that's the cheapest approach. For a component with hundreds of bindings and a signal that changes many times per second, finer-grained systems like Solid or Vue can win. The project's gap analysis lists this explicitly, along with its best-known cost: selecting a row re-runs 1,000 effects (about 1.3 ms, tied with Preact and Angular in the benchmark) and is the top candidate for a per-binding refinement.
Takeaways
- Signals plus an
activepointer give automatic dependency tracking in about 130 lines. - Microtask batching makes writes cheap and cleanup simple, at the cost of async DOM visibility.
- Blueprints and delegated events replace the virtual DOM and per-element closures.
- One effect per view is a conscious trade-off, and it's documented with its costs.
Next: the security model, and the six holes I found in my own framework.
Top comments (0)