Skip to content

Angular Signals and Zoneless Change Detection

expert19 min read

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

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.

Quiz
Why is Zone.js problematic for Angular's performance?

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.

Info

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:

  1. A signal changes (e.g., count.set(5))
  2. Angular knows which computed values depend on count
  3. Angular knows which components read those computed values in their templates
  4. Only those specific components are scheduled for re-rendering
  5. 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
Quiz
What makes zoneless change detection possible in Angular?

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.

AspectSignalsRxJS Observables
Mental modelCurrent value containerStream of values over time
EvaluationSynchronous, pull-based (lazy)Asynchronous, push-based (eager)
BackpressureN/A (always has current value)Operators like throttle, debounce, switchMap
CancellationAutomatic via dependency graphManual subscription management
Best forUI state, derived values, template bindingsEvent streams, HTTP, WebSocket, complex async flows
Compositioncomputed() chainspipe() operator chains
Glitch-freeYes (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.

Quiz
When should you use RxJS Observables instead of Angular Signals?

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:

  1. Zone.js + Signals: Use signals alongside Zone.js. Change detection still works the old way, but signal-based components are more efficient.
  2. OnPush + Signals: Move components to OnPush change detection strategy. They only check when inputs change, events fire, or signals update.
  3. 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.

Key Rules
  1. 1Angular Signals provide the same three primitives as every signal system: signal(), computed(), effect()
  2. 2Zoneless change detection eliminates Zone.js, reducing bundle size by ~33KB and improving render performance by 30-40%
  3. 3Signals and RxJS complement each other: signals for current state, Observables for async streams
  4. 4Use computed() for derived state, never effect() -- Angular warns against writing signals inside effects
  5. 5Signal-based inputs, model signals, and signal queries replace decorators with reactive primitives
What developers doWhat 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