Understanding Signals in Angular
Angular's change detection has historically worked by checking the entire component tree for changes whenever anything might have happened — an event fired, a timer resolved, an HTTP response came back. Signals replace that broad, zone-based sweep with something much more precise: reactive values that know exactly which parts of a template depend on them, so only those parts need to update.
The problem signals solve
Classic Angular relies on Zone.js to patch async APIs (setTimeout, addEventListener, fetch, and so on) so it knows when to run change detection. But it doesn't know what actually changed — so its default strategy is to re-check every component from the root down, comparing old and new values by reference. OnPush change detection narrows this by skipping components whose @Input() references haven't changed, but you still have to opt into it, and you still have to make sure you're always producing new object/array references when data does change.
// Without OnPush: this component gets re-checked on every zone event,
// anywhere in the app, whether count actually changed or not
@Component({
selector: 'app-counter',
template: `<p>Count: {{ count }}</p>`,
})
export class CounterComponent {
count = 0;
}
A signal is a reactive container
import { signal } from '@angular/core';
const count = signal(0);
console.log(count()); // 0 — signals are read by calling them as functions
count.set(1);
count.update((n) => n + 1);
console.log(count()); // 2
The important part isn't the getter/setter syntax — it's that reading a signal inside a reactive context (a template, a computed(), an effect()) automatically registers that context as a dependent. When the signal changes, Angular knows precisely which templates and computations actually depend on it, without needing Zone.js to guess by re-checking everything.
@Component({
selector: 'app-counter',
template: `
<p>Count: {{ count() }}</p>
<button (click)="increment()">+1</button>
`,
})
export class CounterComponent {
count = signal(0);
increment() {
this.count.update((n) => n + 1);
}
}
Derived state with computed()
computed() creates a signal whose value is derived from other signals, and it recalculates automatically — and only — when one of its dependencies actually changes.
const price = signal(29.99);
const quantity = signal(3);
const total = computed(() => price() * quantity());
console.log(total()); // 89.97
quantity.set(5);
console.log(total()); // 149.95
Crucially, computed() is lazy and memoized: it doesn't recompute on every read, only when a dependency changed since the last read. Reading total() twice in a row without touching price or quantity in between returns the cached value both times.
Side effects with effect()
Sometimes you need to do something when a signal changes — log it, sync it to localStorage, trigger an animation — rather than derive a new value from it. That's what effect() is for:
import { effect, signal } from '@angular/core';
const theme = signal<'light' | 'dark'>('light');
effect(() => {
document.body.className = theme(); // reruns whenever theme() changes
localStorage.setItem('theme', theme());
});
effect() runs once immediately to establish its dependencies, then reruns whenever any signal it read during that run changes. Like computed(), dependency tracking is automatic — you never declare a dependency array; the framework figures it out from which signals you actually called during the function body.
Signals and OnPush work together, automatically
A component using signals in its template gets fine-grained updates without manually opting into OnPush and manually managing immutable references — Angular can skip re-checking a component entirely if none of the signals it reads have changed, because it knows exactly which signals each template expression depends on.
@Component({
selector: 'app-cart-total',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<p>Total: {{ total() | currency }}</p>`,
})
export class CartTotalComponent {
price = input.required<number>();
quantity = input.required<number>();
total = computed(() => this.price() * this.quantity());
}
input() (Angular's signal-based input API) makes @Input() bindings themselves signals, so a parent updating a bound input automatically flows through computed() and into the template with the same fine-grained tracking — no manual ngOnChanges comparison needed.
What signals don't replace
Signals model synchronous, reactive state — they're not a replacement for RxJS Observables, which are built for modeling asynchronous event streams (HTTP responses, WebSocket messages, user input streams you want to debounce/switchMap/etc.). Angular provides toSignal() and toObservable() specifically because both models are useful for different things: reach for an Observable when you're composing async event streams, and a signal when you're representing a current value that a template needs to react to.
Takeaway
Zone.js-based change detection asks "did anything happen?" and re-checks broadly to find out. Signals invert that: each signal knows exactly which computations and templates depend on it, so updates are targeted instead of swept. That precision is also what unlocks Angular's fully zoneless mode — once change detection is driven by signals telling the framework exactly what changed, there's nothing left for Zone.js to patch.