DEV Community

Duvan Jamid Vargas
Duvan Jamid Vargas

Posted on

No Zone.js, No Virtual DOM: How Signals and a Microtask Update Only What Changed (Inside AngeLite, Part 2)

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;
}
Enter fullscreen mode Exit fullscreen mode

Three details are doing a lot of work:

  1. active is 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.
  2. Subscriber sets are allocated lazily, so a signal nobody reads costs almost nothing.
  3. Object.is short-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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. clone the blueprint's fragment,
  2. walk it once applying the instructions,
  3. 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>
}
Enter fullscreen mode Exit fullscreen mode

@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

  1. Plain properties don't repaint. this.name = 'Ada' notifies nobody. State you want reflected must be a signal: name = signal('Ada').
  2. 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]).
  3. 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 a computed).

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 active pointer 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)