Angular Signals: Fine-Grained Reactivity Without Zone.js Overhead
Angular Signals replace zone.js change detection with fine-grained reactivity. This guide shows a practical service pattern, RxJS interop, signal-based inputs/outputs, and the five most common mistakes with verification steps.
17 Sept 2025, 00:00 UTC

The Problem: Zone.js Change Detection Doesn't Scale
Angular's default change detection runs a full component-tree check after every async event—clicks, timers, HTTP responses, Promise resolutions. In a dashboard with hundreds of components, a single setTimeout callback can trigger thousands of template evaluations, most of which produce no DOM changes. This scripting overhead shows up as long tasks in Chrome DevTools and degrades interaction latency.
Signals, stable since Angular 17 and the default reactivity model in v19+, replace this coarse-grained mechanism with fine-grained dependency tracking. When a signal value changes, only the specific template bindings or computed derivations that read that signal re-evaluate. No zone.js tick, no full-tree walk.
How Signals Work: A Worked Service Pattern
The most practical adoption path for existing codebases is encapsulating global or shared state in an injectable service. This avoids NgRx boilerplate while giving components a typed, reactive API.
// src/app/store/counter.store.ts
import { Injectable, signal, computed, effect } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class CounterStore {
private _count = signal(0, {
// Custom equality for objects/arrays; primitives use Object.is by default
equal: (a: number, b: number) => a === b
});
// Public readonly signal—components can read but not write
readonly count = this._count.asReadonly();
// Derived state, memoized until dependencies change
readonly double = computed(() => this._count() * 2);
readonly isEven = computed(() => this._count() % 2 === 0);
// Side-effect: logging, analytics, persistence
constructor() {
effect(() => {
console.log('[CounterStore] count changed to', this._count());
// Avoid async work here; see Limits section
});
}
// Mutation methods—call these from event handlers
increment() { this._count.update(v => v + 1); }
decrement() { this._count.update(v => v - 1); }
reset() { this._count.set(0); }
};
Components inject the store and read signals directly in templates:
// src/app/counter/counter.component.ts
import { Component, inject } from '@angular/core';
import { CounterStore } from '../store/counter.store';
@Component({
selector: 'app-counter',
standalone: true,
template: `
Count: {{ store.count() }}
Double: {{ store.double() }}
Even? {{ store.isEven() ? 'Yes' : 'No' }}
+
-
Reset
`,
changeDetection: ChangeDetectionStrategy.OnPush // default in v19+
})
export class CounterComponent {
store = inject(CounterStore);
};
When increment() runs, only the three text bindings update. The component's OnPush change detector never runs. In DevTools Performance tab, you'll see a single microtask with no "Angular tick" event.
Interop: Bridging Observables and Signals
Existing RxJS streams integrate via toSignal and toObservable (from @angular/core/rxjs-interop). This lets you migrate incrementally.
// Converting an HTTP observable to a signal
import { toSignal } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
import { map } from 'rxjs/operators';
@Injectable({ providedIn: 'root' })
export class UserStore {
private user$ = this.http.get<User>('/api/user').pipe(map(u => u.name));
// initialValue prevents template errors before first emission
readonly userName = toSignal(this.user$, { initialValue: 'Loading...' });
constructor(private http: HttpClient) {}
};
Conversely, toObservable(signal) lets signal-based state feed legacy components or operators that expect Observables.
Component Inputs and Outputs: Signal-Based APIs
Angular 17+ introduces input() and output() functions that replace @Input()/@Output() decorators. They're signals and event emitters respectively, enabling reactive patterns in parent templates.
// Child component
import { Component, input, output } from '@angular/core';
@Component({
selector: 'app-item',
standalone: true,
template: `{{ label() }}`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ItemComponent {
readonly label = input.required<string>();
readonly select = output<void>();
};
// Parent template
<app-item
[label]="store.selectedItem()?.name ?? 'None'"
(select)="store.selectItem($event)"
/>
The parent's store.selectedItem() signal read subscribes the parent template. When the store updates, only the label binding re-evaluates.
Limits and Common Mistakes
1. Reference Equality for Objects and Arrays
Signals use Object.is for equality. Mutating an array or object in place won't trigger updates:
// ❌ Wrong: mutates in place, no notification
const items = signal<string[]>([]);
items().push('new'); // template doesn't update
// ✅ Correct: new reference
items.update(arr => [...arr, 'new']);
// ✅ Or provide custom equality
const items = signal<string[]>([], {
equal: (a, b) => a.length === b.length && a.every((v, i) => v === b[i])
});
2. Effects Run Synchronously and Repeatedly
effect(() => { ... }) executes after the current microtask and again on every dependency change. Heavy computation or async work inside effects blocks the main thread and can cause infinite loops if the effect mutates its own dependencies.
// ❌ Dangerous: async in effect
effect(() => {
const data = fetchData(signal()); // runs on every change
this.saveToBackend(data); // may trigger more changes
});
// ✅ Guard async work with a condition
effect(() => {
if (this.shouldSave()) {
this.saveToBackend(this.data());
}
});
// ✅ Better: move async to event handlers
onSaveClick() { this.saveToBackend(this.data()); }
3. Computed Signals Are Lazy
A computed only recalculates when something reads it. If no template or effect consumes a computed, its derivation function never runs—this can hide bugs where developers expect side-effects inside the computation.
// ❌ Side-effect in computed—unreliable
const bad = computed(() => {
console.log('computing'); // only logs when read
return this.value() * 2;
});
// ✅ Keep computed pure; use effect for side-effects
const good = computed(() => this.value() * 2);
effect(() => console.log('value doubled:', good()));
4. Zone.js Interop Can Cause Double Detection
Calling signal.set() inside a zone.js callback (e.g., setTimeout, Promise.then) works, but if the component also uses default change detection, you'll get both signal updates and a full zone tick. Prefer NgZone.runOutsideAngular for non-signal async work.
import { NgZone } from '@angular/core';
constructor(private zone: NgZone) {}
startPolling() {
this.zone.runOutsideAngular(() => {
setInterval(() => {
// Signal update won't trigger zone.js tick
this.store.increment();
}, 1000);
});
}
5. Testing Requires Explicit Flush
TestBed doesn't automatically flush signal effects. After mutating a signal in a unit test, call fixture.detectChanges() or use waitForAsync with tick(). For input() signals, use setInput(fixture.componentRef, 'prop', value) from @angular/core/testing.
// Example test pattern
it('updates template when signal changes', () => {
fixture = TestBed.createComponent(CounterComponent);
const store = TestBed.inject(CounterStore);
store.increment();
fixture.detectChanges(); // flushes signal updates
expect(fixture.nativeElement.textContent).toContain('Count: 1');
});
Verifying the Performance Gain
Create a minimal benchmark to confirm signals eliminate zone.js ticks for your use case:
- Run
ng new signals-perf --standalone --ssr=false(requires Angular CLI 19+). - Generate a list component rendering 10,000 items with
@forover a signal array. - Add a button that updates a single item via
items.update(arr => arr.map(...)). - Open Chrome DevTools → Performance, record a click, and check the Timeline. You should see a single scripting event under 5 ms with no "NgZone" or "tick" entries.
- Compare with a zone.js
*ngForversion: the same update will show a full change detection cycle across all 10,000 rows.
This verification runs locally; no production deployment needed. The key metric is "Scripting" time in the Performance tab—signals should reduce it by an order of magnitude for partial updates.
When to Adopt
Signals are production-ready for new projects on Angular 17+. For existing apps, migrate leaf components first—those with no children using zone.js—then work upward. The service pattern shown here works alongside zone.js components; you don't need a full rewrite. Set ChangeDetectionStrategy.OnPush on signal-based components (default in v19) to opt out of zone.js entirely for that subtree.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.