Entwicklung & Code
Angular Signals: Elegante Reaktivität als Architekturfalle
Mit Angular 17 hielten Signals 2023 offiziell Einzug in das Framework. Sie versprechen eine modernere, klarere Reaktivität: weniger Boilerplate-Code, bessere Performance. Gerade im Template- und Komponentenbereich lösen sie viele Probleme eleganter als klassische Observable-basierte Ansätze.
Nicolai Wolko ist Softwarearchitekt, Consultant und Mitgründer der WBK Consulting AG. Er unterstützt Unternehmen bei komplexen Web- und Cloudprojekten und wirkt als Sparringspartner sowie Gutachter für CTOs. Fachbeiträge zur modernen Softwarearchitektur veröffentlicht er regelmäßig in Fachmedien und auf seinem Blog.
Statt Subscriptions, pipe()
und komplexen Streams genügen nun wenige Zeilen mit signal()
, computed()
und effect()
. Der Code wirkt schlanker, intuitiver und näher am User Interface (UI).
Da liegt die Idee nahe: Wenn Signals im UI überzeugen, warum nicht auch in der Applikationslogik? Warum nicht RxJS vollständig ersetzen? Ein Application Store ohne Actions, Meta-Framework und Observable: direkt, deklarativ, minimalistisch.
Ein Ansatz, der im Folgenden anhand eines konkreten Fallbeispiels analysiert und kritisch hinterfragt wird. Anschließend wird behandelt, in welchen Kontexten sich Signals sinnvoll einsetzen lassen.
Aufbau des Fallbeispiels
Auf den ersten Blick besitzt dieses Beispiel einen klar strukturierten Architekturansatz. Doch der Wandel beginnt unauffällig. RxJS bleibt zunächst außen vor. Das UI reagiert flüssig, der Code bleibt übersichtlich. Komplexe Streams, verschachtelte Operatoren oder eigenes Subscription Handling entfallen. Stattdessen kommen Signals zum Einsatz. Es liegt nahe, diese unkomplizierte Herangehensweise auch für die Applikationslogik zu übernehmen. Im folgenden Beispiel übernimmt ein ProductStore
die Zustandslogik. Signals organisieren Kategorien, Filter und Produktdaten – reaktiv und direkt.
@Injectable({ providedIn: 'root' })
export class ProductStore {
private allProducts = signal([]);
readonly selectedCategory = signal('Bücher');
readonly onlyAvailable = signal(false);
readonly productList = computed(() => {
return this.allProducts().filter(p =>
this.onlyAvailable() ? p.available : true
);
});
selectCategory(category: string) {
this.selectedCategory.set(category);
}
toggleAvailabilityFilter() {
this.onlyAvailable.set(!this.onlyAvailable());
}
constructor(private api: ProductApiService) {
effect(() => {
const category = this.selectedCategory();
const onlyAvailable = this.onlyAvailable();
this.api.getProducts(category, onlyAvailable).then(products => {
this.allProducts.set(products);
});
});
}
}
Die Struktur überzeugt zunächst durch Klarheit. Die Komponente konsumiert productList
direkt, ohne eigene Logik. Der Store verwaltet den Zustand, Signals sorgen für die Weitergabe von Änderungen.
Doch mit der nächsten Anforderung ändert sich das Bild: Bestimmte Produkte sollen zwar im Katalog verbleiben, aber im UI nicht mehr erscheinen. Da auch andere Systeme die bestehende API verwenden, ist eine Anpassung nicht möglich. Stattdessen liefert das Backend eine Liste freigegebener Produkt-IDs, anhand derer das UI filtert.
@Injectable({ providedIn: 'root' })
export class ProductStore {
// [...]
readonly backendEnabledProductIds = signal>(new Set());
readonly productList = computed(() => {
return this.allProducts().filter(p =>
this.onlyAvailable() ? p.available : true
).filter(p => this.backendEnabledProductIds().has(p.id));
});
constructor(private api: ProductApiService) {
effect(() => {
const category = this.selectedCategory();
const onlyAvailable = this.onlyAvailable();
this.api.getProducts(category, onlyAvailable).then(products => {
this.allProducts.set(products);
});
});
effect(() => {
this.api.getEnabledProductIds().then(ids => {
this.backendEnabledProductIds.set(new Set(ids));
});
});
}
// [...]
}
Nach außen bleibt die Architektur zunächst unverändert. Die Komponente enthält weiterhin keine eigene Logik, Subscriptions sind nicht notwendig, und die Reaktivität scheint erhalten zu bleiben. Im Service jedoch nimmt die Zahl der effect()
s zu, Abhängigkeiten werden vielfältiger, und die Übersichtlichkeit leidet.
Nach und nach wandert Logik in verteilte effect()
s, bis ihre Zuständigkeiten kaum noch greifbar sind. Aus einem überschaubaren ViewModel entsteht ein Gebilde mit immer mehr impliziten Reaktionen – eine Entwicklung, die ein waches Auge für Architektur erfordert.
Wenn reaktive Systeme entgleisen
Das Setup wirkt zunächst unspektakulär. Die Produktliste wird über ein computed()
erstellt, gefiltert nach Verfügbarkeit und den vom Backend freigegebenen IDs. Zwei effect()
s laden die Daten.
Der Code wirkt aufgeräumt und lässt sich modular erweitern. Doch der nächste Feature-Wunsch stellt das System auf die Probe: Die Stakeholder möchten wissen, wie oft bestimmte Kategorien angesehen werden. Die Entwicklerinnen und Entwickler entscheiden sich für einen naheliegenden Ansatz. Eine Änderung der Kategorie löst ein Tracking-Event aus. Ein effect()
scheint dafür perfekt geeignet – unkompliziert und ohne erkennbare Nebenwirkungen:
effect(() => {
const category = this.selectedCategory();
this.analytics.trackCategoryView(category);
});
Schnell eingebaut, kein zusätzlicher State, keine neue Subscription. Eine Reaktion auf das bestehende Signal, unkompliziert und ohne erkennbare Nebenwirkungen. Doch damit verlässt der Code den Bereich kontrollierter Reaktivität.
Der Kipppunkt
Die Annahme ist klar: Ändert sich die Kategorie, wird ein Tracking ausgelöst. Was dabei leicht zu übersehen ist: Signals reagieren nicht auf Bedeutung, sondern auf jede Mutation. Auch wenn set()
denselben Wert schreibt oder zwei Komponenten nacheinander dieselbe Auswahl treffen, passiert zwar technisch etwas, semantisch aber nicht. Das Ergebnis sind doppelte Events und verzerrte Metriken, ohne dass der Code einen Hinweis darauf gibt. Alles sieht korrekt aus.
Das Tracking erfolgt unmittelbar im selben Ausführungstakt (Tick), ohne Möglichkeit zur Entkopplung. Wenn parallel ein weiterer effect()
ausgelöst wird – etwa durch ein zweites Signal –, fehlt jegliche Koordination.
Die Reihenfolge ist nicht vorhersehbar, und das UI kann in einen inkonsistenten Zustand geraten: Daten werden mehrfach geladen, Reaktionen überschneiden sich, Seiteneffekte sind nicht mehr eindeutig zuzuordnen. Mit jedem zusätzlichen effect()
steigt die Zahl impliziter Wechselwirkungen. Was wie ein reagierendes System wirkt, ist längst nicht mehr entscheidungsfähig.
In einem Kundenprojekt führte genau dieser Zustand dazu, dass ein effect()
mehrfach pro Sekunde auslöste. Nicht wegen einer echten Änderung, sondern weil derselbe Wert mehrfach gesetzt wurde. Das UI zeigte korrekte Daten, aber das Backend war mit redundanten Anfragen überlastet.
Das Missverständnis
effect()
wirkt wie ein deklarativer Controller: „Wenn sich X ändert, tue Y.“ Doch in Wirklichkeit ist es ein reaktiver Spion. Er beobachtet jedes Signal, das gelesen wird, unabhängig von der semantischen Bedeutung. Er feuert sogar dann, wenn niemand es erwartet. Und er ist nicht koordiniert. Jeder effect()
lebt in seiner eigenen Welt, ohne zentrale Regie.
Was als architektonische Vereinfachung begann, endet in einer Blackbox aus Zuständen, Reaktionen und Nebenwirkungen. Mit jedem weiteren Feature wächst diese Komplexität. Es gibt keinen großen Knall, aber eine zuvor elegant erscheinende Struktur driftet leise auseinander.