Angular Signals and Zoneless Change Detection
Angular's Biggest Architectural Shift Since Ivy
For years, Angular's change detection was powered by Zone.js -- a library that monkey-patches every async API (setTimeout, Promise, addEventListener, fetch) to tell Angular "something happened, check everything." It worked, but at a cost: ~33KB of bundle weight, 30-40% slower rendering from full tree traversal, and a runtime that couldn't distinguish a user click from a random setTimeout.
Angular Signals changed everything. Introduced in Angular 16, stabilized in Angular 17, and made the default reactivity model by Angular 20, signals give Angular what it never had: knowledge of exactly what changed and exactly what depends on it. As of Angular 21, Zone.js is no longer included by default.
@Component({
template: `
<button (click)="increment()">Count: {{ count() }}</button>
<p>Doubled: {{ doubled() }}</p>
`
})
export class CounterComponent {
count = signal(0);
doubled = computed(() => this.count() * 2);
increment() {
this.count.update(c => c + 1);
}
}
The Mental Model
Think of Zone.js like a fire alarm system that goes off for every single event in the building -- someone opens a door, a phone rings, a light flickers. Every time the alarm sounds, security (Angular) has to check every room (component) to see if anything actually changed. Most of the time, nothing did.
Angular Signals replace this with smart sensors on each room. When something changes in Room 5, only Room 5's sensor fires, and security knows exactly which room to check. No building-wide alarm, no checking every room, no wasted effort.
The Zone.js Problem
Zone.js works by patching browser APIs at the global level:
// Zone.js conceptually does this:
const originalSetTimeout = window.setTimeout;
window.setTimeout = function(cb, delay) {
return originalSetTimeout(() => {
cb();
angular.tick(); // "Something happened! Check all components!"
}, delay);
};
Every setTimeout, every Promise.then, every click handler, every fetch response triggers angular.tick(). Angular then walks the entire component tree (or the subtree if using OnPush), checks every binding expression, and updates the DOM where values differ.
This has cascading problems:
The waste is in step 3. A setTimeout in a utility service triggers Angular to check the bindings in your header, sidebar, footer, every list item, every form field. Most of them didn't change.
Angular Signals: The Three Primitives
Angular's signal API follows the same three-primitive pattern as every signal system:
signal() -- Writable Reactive State
import { signal } from '@angular/core';
const count = signal(0);
count(); // Read: 0 (tracked in reactive contexts)
count.set(5); // Write: set to 5
count.update(c => c + 1); // Write with updater: set to 6
Angular signals use function-call syntax for reads (like Solid's createSignal getter) and methods for writes. This makes reads trackable -- the function call is what triggers dependency registration.
computed() -- Derived State
import { signal, computed } from '@angular/core';
const price = signal(100);
const tax = signal(0.08);
const total = computed(() => price() * (1 + tax()));
total(); // 108 -- tracked and cached
Angular's computed is lazy and cached, just like Solid's createMemo and Vue's computed(). It only recalculates when a dependency changes and the value is read.
effect() -- Side Effects
import { signal, effect } from '@angular/core';
const userId = signal(1);
effect(() => {
console.log(`Loading user ${userId()}`);
loadUser(userId());
});
Angular effects run in an injection context by default, meaning they respect the component lifecycle and are automatically cleaned up when the component is destroyed.
Angular's effect() is intentionally limited compared to Solid or Vue. The Angular team discourages using effects for state synchronization (writing to signals inside effects). They warn that effects should be for side effects only -- DOM manipulation, logging, analytics. For derived state, always use computed(). Angular even shows development-mode warnings when you write to signals inside effects.
Zoneless Change Detection
With signals, Angular knows exactly which components have dirty state. This enables zoneless change detection -- no Zone.js, no global monkey-patching, no tree walks.
// Angular 21+: zoneless by default
// bootstrapApplication config
export const appConfig: ApplicationConfig = {
providers: [
provideZonelessChangeDetection(),
provideRouter(routes)
]
};
With zoneless change detection:
- A signal changes (e.g.,
count.set(5)) - Angular knows which computed values depend on
count - Angular knows which components read those computed values in their templates
- Only those specific components are scheduled for re-rendering
- During re-rendering, only the changed bindings are evaluated
Performance Impact
The numbers are significant:
- Bundle size: ~33KB reduction (Zone.js removed)
- Rendering speed: 30-40% faster with signal-only targeted updates
- Memory: 15-20% less with zoneless (no Zone.js overhead, no unnecessary change detection cycles)
- Initial load: ~12% faster on enterprise applications
Signal-Based Components: The New Patterns
Input Signals
Angular now supports signal-based inputs, replacing the decorator-based @Input():
@Component({
template: `<h1>{{ name() }}</h1>`
})
export class GreetingComponent {
name = input<string>(); // Required input signal
theme = input('light'); // Optional input with default
size = input.required<number>(); // Explicit required
}
Input signals are read-only signals. When a parent passes a new value, the signal updates and any computed values or template bindings that depend on it automatically re-evaluate.
Model Signals
Model signals enable two-way binding with signal semantics:
@Component({
template: `
<input [value]="value()" (input)="value.set($event.target.value)" />
`
})
export class TextInputComponent {
value = model<string>(''); // Two-way bindable signal
}
// Parent usage:
// <app-text-input [(value)]="searchQuery" />
Signal Queries
@Component({ /* ... */ })
export class ListComponent {
items = viewChildren(ItemComponent); // Signal<ItemComponent[]>
header = viewChild('header'); // Signal<ElementRef | undefined>
}
View queries return signals that update when the queried elements change, replacing the old @ViewChild and @ViewChildren decorators with reactive primitives.
Signals vs RxJS: Complementary, Not Competing
A common misconception: Angular Signals replace RxJS. They don't. They solve different problems.
| Aspect | Signals | RxJS Observables |
|---|---|---|
| Mental model | Current value container | Stream of values over time |
| Evaluation | Synchronous, pull-based (lazy) | Asynchronous, push-based (eager) |
| Backpressure | N/A (always has current value) | Operators like throttle, debounce, switchMap |
| Cancellation | Automatic via dependency graph | Manual subscription management |
| Best for | UI state, derived values, template bindings | Event streams, HTTP, WebSocket, complex async flows |
| Composition | computed() chains | pipe() operator chains |
| Glitch-free | Yes (topological evaluation) | No (each operator emits independently) |
Angular provides interop functions:
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
// Observable to Signal
const data = toSignal(this.http.get('/api/data'), { initialValue: [] });
// Signal to Observable
const count$ = toObservable(this.count);
count$.pipe(debounceTime(300)).subscribe(/* ... */);
toSignal converts an Observable into a signal, subscribing automatically and unsubscribing when the component is destroyed. toObservable creates an Observable that emits whenever the signal changes, bridging into the RxJS world.
Angular 21: Signal Forms and Beyond
Angular 21 (released late 2025) introduced Signal Forms -- an experimental forms API built entirely on signals:
const name = signal('');
const email = signal('');
const isValid = computed(() => name().length > 0 && email().includes('@'));
// Signal Forms provide reactive validation, dirty tracking, and
// touched state -- all as signals that compose naturally
Signal Forms aim to replace Reactive Forms and Template-driven Forms with a simpler, signal-native API. While still experimental, they represent Angular's commitment to making signals the foundation of every API.
Angular 21 also ships a migration tool (onpush_zoneless_migration) that analyzes your codebase and generates a migration plan from Zone.js to zoneless change detection, making the transition practical for large enterprise codebases.
How Angular handles the Zone.js to zoneless migration
The migration isn't a cliff -- Angular supports a gradual path:
- Zone.js + Signals: Use signals alongside Zone.js. Change detection still works the old way, but signal-based components are more efficient.
- OnPush + Signals: Move components to OnPush change detection strategy. They only check when inputs change, events fire, or signals update.
- Zoneless: Remove Zone.js entirely. All change detection is signal-driven.
The migration tool scans for patterns that depend on Zone.js (async operations that implicitly trigger change detection) and suggests explicit signal-based replacements. For most apps, the hardest part is third-party libraries that rely on Zone.js -- but as the ecosystem moves to zoneless, this becomes less of an issue.
- 1Angular Signals provide the same three primitives as every signal system: signal(), computed(), effect()
- 2Zoneless change detection eliminates Zone.js, reducing bundle size by ~33KB and improving render performance by 30-40%
- 3Signals and RxJS complement each other: signals for current state, Observables for async streams
- 4Use computed() for derived state, never effect() -- Angular warns against writing signals inside effects
- 5Signal-based inputs, model signals, and signal queries replace decorators with reactive primitives
| What developers do | What they should do |
|---|---|
| Writing to signals inside effect() for state synchronization Angular intentionally discourages signal writes in effects because it leads to cascading updates and makes data flow hard to trace. computed() handles derived state without side effects | Use computed() for derived state, effect() only for true side effects |
| Removing Zone.js without migrating async change detection triggers Code that calls setTimeout or subscribes to Observables without updating signals will silently stop triggering change detection in zoneless mode | Use the migration tool and audit all async operations that implicitly rely on Zone.js for change detection |
| Using toSignal without an initial value for synchronous template access HTTP Observables emit asynchronously. The signal is undefined until the first emission. Templates that read the signal before emission get undefined, causing errors or blank UI | Always provide initialValue to toSignal or handle the undefined state |