#Signals vs plain properties
A plain class property is just a slot in memory. When you write this.count = 5, nothing else in the program finds out: JavaScript has no hook that says "this field changed". So for years Angular had to guess when your data might have changed. It used zone.js to notice every click, timer and HTTP response, and then re-checked every binding in the app, just in case (see zone.js and the old model).
A signal is a value wrapped in an object that knows who is reading it. You read it by calling it — count() — and you change it through its API — count.set(5). Because every read and write goes through the signal, Angular can record who read it (a template, a computed, an effect) and, on a write, tell exactly those readers that they are out of date. Nothing has to be guessed.
A good mental model is a spreadsheet. A signal is a cell holding a value. A computed is a cell with a formula (=A1*B1). A component template is a formula too: it reads some cells and displays the result. When you type a new value into A1, the spreadsheet doesn't recalculate every cell in the workbook — it recalculates the ones whose formula references A1. That is the whole idea behind signals, and it's why Angular 22 can run without zone.js (see Zoneless Angular).
import { Component, signal } from '@angular/core';
@Component({
selector: 'app-plain-default',
template: `<p>{{ label }}</p>`,
})
export class PlainLabel {
label = 'start';
later() {
setTimeout(() => (this.label = 'changed'));
}
}
@Component({
selector: 'app-sig',
template: `<p>{{ label() }}</p>`,
})
export class SignalLabel {
label = signal('start');
later() {
setTimeout(() => this.label.set('changed'));
}
}- Both components start by rendering
start. In the second one the template callslabel(). That call happens while Angular is running the template, inside a reactive context, so the signal records "this component's template depends on me". later()is called on each, and asetTimeoutfires a moment later. InPlainLabelthe callback writesthis.label = 'changed'. That's an ordinary assignment: the class field changes, but nothing tells Angular. In a zoneless Angular 22 app the DOM keeps showingstart. A test confirmed it:componentInstance.labelwas'changed'while the<p>still saidstart.- In
SignalLabelthe callback callsthis.label.set('changed'). The signal compares the new value with the old one (Object.is), sees a difference, and notifies its consumers — here, the template ofSignalLabel. - Notifying the template marks that view for refresh and asks Angular's scheduler to run change detection. On that pass Angular re-runs only that template,
label()returns'changed', and the<p>updates. - The difference isn't speed; it's information. A plain property can't announce that it changed; a signal does. In the old world — zone.js plus the old default (now called
Eager) strategy — both would have updated, because zone.js re-checked every component after every timer callback. The signal version doesn't need that guesswork.
| Plain property | Signal | |
|---|---|---|
| Read | this.count | this.count() |
| Write | this.count = 1 | this.count.set(1) / update(fn) |
| Angular knows it changed | No — it has to re-check to find out | Yes — the write notifies its readers |
| Derived values | getters or methods that re-run on every check | computed(): cached until an input changes |
| Works without zone.js | Only if something else triggers a check | Yes |