Signals in Angular verstehen
Angulars Change Detection hat historisch funktioniert, indem sie den gesamten Komponentenbaum auf Änderungen prüft, wann immer irgendetwas passiert sein könnte – ein Event feuerte, ein Timer lief ab, eine HTTP-Antwort kam zurück. Signals ersetzen diesen breiten, zonenbasierten Durchlauf durch etwas deutlich Präziseres: reaktive Werte, die genau wissen, welche Teile eines Templates von ihnen abhängen, sodass nur diese Teile aktualisiert werden müssen.
Das Problem, das Signals lösen
Klassisches Angular verlässt sich auf Zone.js, um asynchrone APIs (setTimeout, addEventListener, fetch und so weiter) zu patchen und so zu wissen, wann die Change Detection laufen soll. Aber es weiß nicht, was sich tatsächlich geändert hat – deshalb prüft die Standardstrategie jede Komponente von der Wurzel abwärts neu und vergleicht alte und neue Werte per Referenz. OnPush-Change-Detection grenzt das ein, indem Komponenten übersprungen werden, deren @Input()-Referenzen sich nicht geändert haben, aber man muss sich dafür entscheiden, und man muss sicherstellen, dass man bei Datenänderungen immer neue Objekt-/Array-Referenzen erzeugt.
// 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;
}
Ein Signal ist ein reaktiver 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
Das Wichtige ist nicht die Getter-/Setter-Syntax – sondern dass das Lesen eines Signals in einem reaktiven Kontext (einem Template, einem computed(), einem effect()) diesen Kontext automatisch als abhängig registriert. Ändert sich das Signal, weiß Angular genau, welche Templates und Berechnungen tatsächlich davon abhängen, ohne dass Zone.js durch erneutes Prüfen von allem raten muss.
@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);
}
}
Abgeleiteter Zustand mit computed()
computed() erzeugt ein Signal, dessen Wert aus anderen Signals abgeleitet ist, und es berechnet sich automatisch neu – und nur dann –, wenn sich tatsächlich eine seiner Abhängigkeiten ändert.
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
Entscheidend ist, dass computed() lazy und memoisiert ist: Es berechnet nicht bei jedem Lesen neu, sondern nur, wenn sich seit dem letzten Lesen eine Abhängigkeit geändert hat. Zweimal hintereinander total() zu lesen, ohne dazwischen price oder quantity anzufassen, liefert beide Male den gecachten Wert.
Seiteneffekte mit effect()
Manchmal muss man etwas tun, wenn sich ein Signal ändert – es loggen, in localStorage synchronisieren, eine Animation auslösen –, statt einen neuen Wert daraus abzuleiten. Dafür ist effect() da:
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() läuft einmal sofort, um seine Abhängigkeiten zu etablieren, und läuft dann erneut, wann immer sich ein während dieses Laufs gelesenes Signal ändert. Wie bei computed() ist das Abhängigkeits-Tracking automatisch – man deklariert nie ein Abhängigkeits-Array; das Framework findet es heraus, anhand der Signals, die man im Funktionskörper tatsächlich aufgerufen hat.
Signals und OnPush arbeiten automatisch zusammen
Eine Komponente, die Signals in ihrem Template verwendet, bekommt feingranulare Updates, ohne sich manuell für OnPush zu entscheiden und manuell unveränderliche Referenzen zu verwalten – Angular kann die erneute Prüfung einer Komponente komplett überspringen, wenn sich keines der von ihr gelesenen Signals geändert hat, weil es genau weiß, von welchen Signals jeder Template-Ausdruck abhängt.
@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() (Angulars Signal-basierte Input-API) macht die @Input()-Bindings selbst zu Signals, sodass ein Elternelement, das einen gebundenen Input aktualisiert, automatisch durch computed() und ins Template fließt, mit demselben feingranularen Tracking – kein manueller ngOnChanges-Vergleich nötig.
Was Signals nicht ersetzen
Signals modellieren synchronen, reaktiven Zustand – sie sind kein Ersatz für RxJS-Observables, die dafür gebaut sind, asynchrone Ereignisströme zu modellieren (HTTP-Antworten, WebSocket-Nachrichten, Nutzereingabeströme, die man debouncen/switchMappen möchte usw.). Angular bietet toSignal() und toObservable() gerade deshalb, weil beide Modelle für unterschiedliche Dinge nützlich sind: Greife zu einem Observable, wenn du asynchrone Ereignisströme komponierst, und zu einem Signal, wenn du einen aktuellen Wert darstellst, auf den ein Template reagieren muss.
Fazit
Zonenbasierte Change Detection fragt „ist irgendetwas passiert?" und prüft breit, um es herauszufinden. Signals kehren das um: Jedes Signal weiß genau, welche Berechnungen und Templates von ihm abhängen, sodass Updates gezielt statt flächendeckend erfolgen. Diese Präzision ist auch das, was Angulars vollständig zonenlosen Modus ermöglicht – sobald die Change Detection durch Signals gesteuert wird, die dem Framework genau sagen, was sich geändert hat, bleibt für Zone.js nichts mehr zu patchen.