Chapter 6: Signals, Zone.js vs vDom
Angular has always stood apart from frameworks like React and Vue in how it handles change detection. While React relies on the Virtual DOM (VDOM) to determine what needs updating, Angular historically used a zone-based change detection system.
With Angular v16, the framework introduced signals: a reactive primitive that lets you track and respond to state changes directly. Signals give Angular developers a more predictable, fine-grained, and efficient way of managing reactivity.
6.1 How React and Vue Handle Changes: The Virtual DOM
In a Virtual DOM approach (React, Vue), the UI is represented by a lightweight in-memory copy of the DOM. When state changes:
- The entire component tree (or subtree) is re-rendered.
- The new virtual DOM is compared (diffed) against the old one.
- The framework applies only the differences to the real DOM.
This works well but introduces overhead:
- Constant re-renders.
- Complex diffing algorithms.
- Unclear data flow for beginners.
6.2 How Angular Traditionally Worked: Zone.js
Before signals, Angular relied on zone-based change detection. This means Angular automatically keeps track of all asynchronous events in your app (clicks, HTTP responses, timers, promises, etc.) and re-runs change detection after each event.
Angular does this with the help of Zone.js, a library that monkey-patches browser APIs like setTimeout, addEventListener, and XMLHttpRequest.
6.2.1 How it Works (Step by Step)
- A user interacts with your app (e.g., clicks a button).
- Zone.js intercepts the event.
- After the event handler finishes, Angular’s change detection runs through the entire component tree.
- Angular compares bindings in templates with their current values.
- If something changed, Angular updates the DOM.
6.2.2 Example: Counter with Zones
import { Component } from '@angular/core';
@Component({
selector: 'app-zone-counter',
template: `
<h2>Count: {{ count }}</h2>
<button (click)="increment()">Increment</button>
<button (click)="incrementLater()">Increment in 1s</button>
`
})
export class ZoneCounterComponent {
count = 0;
increment() {
this.count++;
}
incrementLater() {
setTimeout(() => {
this.count++;
// No need to tell Angular — Zone.js triggers change detection
}, 1000);
}
}
What happens:
- When you click Increment, Angular detects the click event, updates
count, and re-renders the view. - When you click Increment in 1s,
setTimeoutis patched by Zone.js. When the callback runs, Zone.js tells Angular: “Hey, something just happened!” Angular then runs change detection, noticescountchanged, and updates the DOM.
Without Zone.js, Angular wouldn’t know the async callback finished, and the UI would stay stale.
6.2.3 The Downside
Zone-based detection is convenient, but it can be expensive:
- Angular re-runs change detection across the entire component tree — even if only one component changed.
- In large apps, this can lead to performance bottlenecks.
- Developers had to manually optimize with:
ChangeDetectionStrategy.OnPushasyncpipes- Immutability patterns
6.3 Enter Angular Signals
Signals are Angular’s answer to fine-grained reactivity.
A signal is a special object that holds a value. When the value changes, Angular knows exactly which components or computed values depend on it and updates only those.
Think of signals as reactive variables with built-in subscriptions.
This eliminates unnecessary re-checking of unrelated components and gives Angular fine-grained precision, similar to Solid.js or Svelte.
Creating a Signal
import { Component, signal } from '@angular/core';
@Component({
selector: 'app-counter',
standalone: true,
template: `
<h2>Count: {{ count() }}</h2>
<button (click)="increment()">Increment</button>
`
})
export class CounterComponent {
// A signal that holds a number
count = signal(0);
increment() {
this.count.update(c => c + 1);
}
}
Notice:
- You call the signal like a function (
count()) to read its value. - You update the signal with
.set(newValue)or.update(callback).
6.4 Computed Signals
You can derive values from signals with computed().
import { computed, signal } from '@angular/core';
@Component({
selector: 'app-cart',
standalone: true,
template: `
<p>Items: {{ items() }}</p>
<p>Total: {{ total() }}</p>
`
})
export class CartComponent {
items = signal([10, 20, 30]);
// Automatically recalculates when items change
total = computed(() => this.items().reduce((a, b) => a + b, 0));
}
Here, total automatically updates when items changes — no manual subscriptions needed.
6.5 Effects
Sometimes you want to react to changes with side effects (logging, API calls, DOM interactions). Use effect().
import { Component, effect, signal } from '@angular/core';
@Component({
selector: 'app-logger',
standalone: true,
template: `
<input [(ngModel)]="username()" (ngModelChange)="username.set($event)">
`
})
export class LoggerComponent {
username = signal('');
constructor() {
effect(() => {
console.log('Username changed:', this.username());
});
}
}
Every time username changes, the effect runs automatically.
Signals vs Angular Zone.js Change Detection
Angular traditionally uses Zone.js for change detection, which patches async APIs (setTimeout, Promise, DOM events) and triggers a global check of the component tree. With Signals, Angular can skip most of this overhead by directly tracking dependencies between state and templates.
Comparison Table
| Feature | Zone.js Change Detection (Traditional Angular) | Signals (Angular 16+) |
|---|---|---|
| Trigger mechanism | Zone.js patches async events (setTimeout, HTTP, user events) → runs global change detection | Directly marks affected signals as dirty |
| Update granularity | Walks the entire component tree (unless OnPush optimizations are used) | Updates only the exact template bindings that depend on changed signals |
| Performance | Can be wasteful on large trees (many unaffected components still checked) | Highly efficient: no tree traversal, no redundant checks |
| Developer mental model | “Angular checks everything after any async event” | “Only dependent values re-render” |
| Optimizations needed | OnPush strategy, immutable patterns, ChangeDetectorRef tweaks | Rarely needed — fine-grained reactivity is built in |
| Debugging | Must understand zones, CD cycles, and OnPush behavior | Intuitive: signals update what they’re connected to |
| Backward compatibility | Default for all Angular apps historically | Can coexist with zones, migration is incremental |
6.7 When to Use Signals
- Managing local component state (like counters, form values).
- Deriving computed values (like totals, filters, derived flags).
- Replacing
BehaviorSubjectfor simpler reactive patterns. - Gradually introducing into existing apps (they coexist with Observables).
6.8 Best Practices
- Use signals for state you want to reactively update in the template.
- Use computed instead of recalculating in the template.
- Use effect only for side effects (logging, DOM manipulation, API calls).
- Don’t overuse signals — large-scale app state may still benefit from NgRx or global stores.