Zustandsverwaltung mit Angular Signals: signal, computed und effect richtig einsetzen
Bei der Zustandsverwaltung in Angular-Anwendungen stellte sich lange dieselbe Frage: Soll dieser Wert ein Feld der Komponente sein, ein BehaviorSubject in einem Service oder in eine Store-Bibliothek wandern? Signals geben darauf eine kleinere und besser vorhersagbare Antwort: einen Wertcontainer, der beim Lesen seine Abhängigen registriert und bei einer Änderung nur diese benachrichtigt.
In diesem Beitrag gehe ich die drei Kernbausteine durch, also signal, computed und effect, wofür jeder gedacht ist und wo ich die Grenze zu RxJS ziehe. Es geht nicht darum, „alles zum Signal zu machen" oder „gar nicht anzufassen", sondern zu entscheiden, wo welcher Zustand leben sollte.
Signal: ein les- und schreibbarer Wert
Ein Signal umhüllt einen Wert, den man wie eine Funktion aufruft, um ihn zu lesen. Geschrieben wird mit set oder update:
import { signal } from '@angular/core';
const count = signal(0);
count(); // 0
count.set(5);
count.update(n => n + 1);
Im Template schreibt man ebenso {{ count() }}. Angular weiß, welche Signals ein Template liest, und wertet die Ansicht nur neu aus, wenn sich eines davon ändert. Früher musste man das von Hand mit der async-Pipe oder dem ChangeDetectorRef steuern.
Ein Detail ist dabei wichtig: Wird ein Signal auf dieselbe Referenz gesetzt, zählt das nicht als Änderung. Objekte und Arrays dürfen also nicht direkt verändert werden. items().push(x) löst nichts aus; items.update(list => [...list, x]) schon. Immutability wird mit Signals zur selbstverständlichen Gewohnheit.
Computed: abgeleiteten Zustand berechnen, nicht speichern
Der häufigste Fehler, den ich sehe: Ein ableitbarer Wert wird in einem eigenen Feld gespeichert und von Hand aktuell gehalten. Warenkorbsumme, gefilterte Liste, „kann das Formular abgeschickt werden": Was sich aus anderem Zustand ableiten lässt, gehört in ein computed:
readonly items = signal<CartItem[]>([]);
readonly taxRate = signal(0.2);
readonly subtotal = computed(() =>
this.items().reduce((sum, i) => sum + i.price * i.quantity, 0));
readonly total = computed(() =>
this.subtotal() * (1 + this.taxRate()));
computed ist lazy und wird zwischengespeichert: Der Wert wird erst berechnet, wenn er gelesen wird, und nicht neu berechnet, solange sich die Abhängigkeiten nicht ändern. Abhängigkeiten werden automatisch erfasst, eine Abhängigkeitsliste ist nicht nötig. Damit verschwindet die Fehlerklasse „zwei Felder sind auseinandergelaufen", denn es gibt nur eine Quelle der Wahrheit.
Innerhalb eines computed sollten keine Seiteneffekte entstehen. Eine HTTP-Anfrage zu starten oder in ein anderes Signal zu schreiben, führt zu Überraschungen, weil man nicht kontrolliert, wann der Wert berechnet wird. Ein computed sollte sich wie eine reine Funktion verhalten.
Effect: für Seiteneffekte, nicht zum Weiterreichen von Zustand
Ein effect ist ein Seiteneffekt, der läuft, wenn sich Signals ändern. Er passt zu Aufgaben, die mit der Welt außerhalb von Angular sprechen: in localStorage schreiben, ein Analytics-Ereignis senden, eine Diagrammbibliothek eines Drittanbieters aktualisieren.
constructor() {
effect(() => {
localStorage.setItem('cart', JSON.stringify(this.items()));
});
}
Falsch eingesetzt wird ein effect, wenn er bei der Änderung eines Signals ein anderes Signal per set setzt. Das macht die Kette schwer lesbar und birgt Schleifenrisiken. Wenn Sie das brauchen, sollte der zweite Wert meistens ein computed sein. Meine Faustregel: Verlässt die Ausgabe Angular, ist es ein effect; bleibt sie im Anwendungszustand, ist es ein computed.
Ein effect muss außerdem in einem Injection-Kontext erstellt werden; der Konstruktor einer Komponente oder eines Services ist dafür der natürliche Ort. Angular verwaltet den Lebenszyklus, Sie müssen nicht von Hand aufräumen.
Zustand im Service: nur lesend nach außen geben
Geteilten Zustand in einem kleinen Service zu halten, genügt für die meisten Anwendungen. Wichtig ist, den Schreibzugriff im Service zu behalten:
@Injectable({ providedIn: 'root' })
export class CartStore {
private readonly _items = signal<CartItem[]>([]);
readonly items = this._items.asReadonly();
readonly count = computed(() => this._items().length);
add(item: CartItem) {
this._items.update(list => [...list, item]);
}
remove(id: string) {
this._items.update(list => list.filter(i => i.id !== id));
}
}
Komponenten lesen items und count, können den Zustand aber nur über benannte Operationen wie add und remove ändern. Wie sich der Zustand ändern kann, lässt sich in einer einzigen Datei nachlesen. Den Großteil der Disziplin einer Store-Bibliothek erhalten Sie ohne zusätzliche Abhängigkeit. Wächst die Anwendung und brauchen Sie Time-Travel, detaillierte Entwicklungswerkzeuge oder ein striktes Ereignismodell, lohnt sich der Blick auf eine Bibliothek.
Die Grenze zu RxJS: Ereignisströme und aktuelle Werte
Signals ersetzen RxJS nicht, sie erfüllen eine andere Aufgabe. Ein Signal beantwortet „Was ist der Wert jetzt?" und hat immer einen Wert. Ein Observable dient Ereignissen über die Zeit: Eingaben in einem Suchfeld, WebSocket-Nachrichten, abbrechbare HTTP-Anfragen, alles, was debounceTime oder switchMap braucht.
Die praktische Trennung:
- Zustand, den die Oberfläche anzeigt: Signal.
- Ereignisströme über die Zeit und Kombinationslogik: RxJS.
- Brücke dazwischen:
toSignalmacht aus einem Observable ein Signal,toObservableaus einem Signal ein Observable.
So können Sie etwa die Sucheingabe mit debounceTime filtern und das Ergebnis per toSignal ans Template geben. Das Timing bleibt in RxJS, das Template liest einfach ein Signal. toSignal kann ohne Anfangswert undefined liefern; legen Sie das mit der Option initialValue ausdrücklich fest.
Häufige Fehler
Das sind die Probleme, die mir immer wieder begegnen:
- Direkte Mutation. Ein Array oder Objekt ändern und erwarten, dass sich das Signal aktualisiert. Erzeugen Sie immer eine neue Referenz.
- Seiteneffekte in computed. Berechnungen sollten rein bleiben.
- Zustandssynchronisation per effect. Ableitbare Werte als
computeddefinieren. - Alles zum Signal machen. Lokale Variablen, die nur einmal gelesen werden, und Konstanten müssen keine Signals sein.
- An der falschen Stelle lesen. Ein Signal außerhalb von effect oder computed zu lesen, etwa in einem Event-Handler, ist in Ordnung, erzeugt aber keine Abhängigkeit. Stellen Sie sicher, dass Sie dort lesen, wo die Verfolgung gebraucht wird.
In kleinen Schritten migrieren
Versuchen Sie nicht, eine bestehende Anwendung auf einmal umzustellen. Schreiben Sie lokalen Zustand in neuen Komponenten mit Signals, überführen Sie kleine Services mit einem BehaviorSubject nacheinander, lassen Sie komplexe Abläufe in RxJS und verbinden Sie beides mit toSignal. Die Tests laufen in jedem Schritt weiter, und das Team gewöhnt sich an das neue Modell.
Der eigentliche Gewinn von Signals ist zuerst Lesbarkeit, dann Performance: Man sieht dem Code an, woher ein Wert kommt, wovon er abhängt und wer ihn ändern darf. Wenn Sie Zustand in signal, abgeleitete Werte in computed und Seiteneffekte nach außen in effect halten, wird Ihr Angular-Code leichter nachzuvollziehen und zu ändern.