Angular hatte lange ein Reaktivitätsproblem. Nicht weil RxJS schlecht ist — RxJS ist hervorragend für das, wofür es gebaut wurde. Das Problem war, dass Angular RxJS für alles benutzt hat. Für HTTP-Requests, für Router-Events, für Form-Validierung. Und eben auch für simplen Component State. Ein BehaviorSubject für einen Boolean-Toggle ist wie ein Lastwagen für den Einkauf beim Bäcker: technisch möglich, aber unverhältnismaessig.
Mit Signals hat Angular ab Version 16 ein primitives Reaktivitätsmodell eingeführt, das genau dieses Problem löst. Kein Overhead für einfache Fälle. Volle Kompatibilität mit RxJS für komplexe Fälle. Und — das ist der eigentliche Punkt — eine Change Detection, die endlich granular arbeiten kann.
Was Signals lösen
Angulars Change Detection war historisch Zone.js-basiert. Jedes asynchrone Event — ein Click, ein Timer, ein HTTP-Response — triggerte einen kompletten Check des Component Trees. Das funktionierte, war aber ineffizient. Zone.js patcht globale APIs wie setTimeout und Promise, was zu subtilen Bugs führt und das Debugging erschwert.
Signals ermöglichen eine fundamental andere Architektur: Push-basierte, granulare Reaktivität. Das Framework weiß exakt, welche Components von einer Änderung betroffen sind, weil die Abhängigkeiten zur Compile-Zeit bekannt sind. Kein Dirty-Checking mehr. Keine Zone.js-Patches. Nur gezielte Updates.
Signals vs. BehaviorSubject: Das mentale Modell
Der Unterschied ist nicht nur syntaktisch. Er ist konzeptuell. Ein BehaviorSubject ist ein Stream — er modelliert Werte über Zeit. Ein Signal ist ein reaktiver Wert — es modelliert den aktuellen Zustand.
// RxJS: Stream-basiert
const count$ = new BehaviorSubject<number>(0);
// Lesen erfordert Subscription oder async pipe
count$.subscribe(value => console.log(value));
// Schreiben
count$.next(count$.value + 1);
// Cleanup nicht vergessen!
// subscription.unsubscribe();
// ---
// Signals: Wert-basiert
const count = signal(0);
// Lesen ist synchron -- einfach aufrufen
console.log(count());
// Schreiben ist explizit
count.set(count() + 1);
// Oder mit update() für Transformationen
count.update(current => current + 1);
// Kein Cleanup nötig. Kein Memory Leak möglich. Das klingt nach einem kleinen Unterschied. In der Praxis ist er enorm. Jeder Angular-Entwickler hat schon vergessene Subscriptions debuggt. Jeder hat schon takeUntilDestroyed() oder async Pipes vergessen. Signals eliminieren diese gesamte Fehlerkategorie für synchronen State.
Die Core API: signal(), computed(), effect()
Die API ist bewusst minimal gehalten. Drei Funktionen decken 90 Prozent der Anwendungsfaelle ab.
signal(initialValue) erstellt einen beschreibbaren reaktiven Wert. Lesen mit signal(), schreiben mit set(), update() oder mutate().
computed(() => expression) leitet einen Wert ab. Wird automatisch neu berechnet, wenn sich eine Abhängigkeit ändert. Ist lazy — die Berechnung passiert erst beim Lesen. Und ist memoized — identische Inputs erzeugen keine Neuberechnung.
effect(() => sideEffect) führt Code aus, wenn sich Abhängigkeiten ändern. Für Logging, Analytics, localStorage-Sync. Nicht für State-Updates — dafür gibt es computed().
import { signal, computed, effect } from '@angular/core';
// Basiswerte
const price = signal(29.99);
const quantity = signal(1);
const taxRate = signal(0.19);
// Abgeleitete Werte -- automatisch synchron
const subtotal = computed(() => price() * quantity());
const tax = computed(() => subtotal() * taxRate());
const total = computed(() => subtotal() + tax());
// Side Effect -- reagiert auf Änderungen
effect(() => {
console.log(`Neuer Gesamtpreis: ${total().toFixed(2)} EUR`);
});
// Eine Änderung propagiert durch den gesamten Graphen
quantity.set(3);
// Console: "Neuer Gesamtpreis: 107.07 EUR" Was hier passiert, ist entscheidend: Angular baut zur Laufzeit einen Dependency Graph auf. total hängt von subtotal und tax ab. subtotal hängt von price und quantity ab. Wenn sich quantity ändert, weiß Angular exakt, welche Computed Values neu berechnet werden müssen — und welche nicht. taxRate hat sich nicht geändert, also wird tax nur neu berechnet, weil subtotal sich geändert hat.
Das ist keine Magie. Das ist ein topologisch sortierter Dependency Graph mit Lazy Evaluation und Memoization. Genau das, was React mit useMemo manuell erfordert — Angular macht es automatisch.
Component State mit Signals
In der Praxis sieht das in Components so aus:
@Component({
selector: 'app-product-filter',
template: `
<div class="filters">
<input
type="text"
[value]="searchTerm()"
(input)="searchTerm.set($any($event.target).value)"
placeholder="Produkt suchen..."
/>
<select (change)="selectedCategory.set($any($event.target).value)">
@for (cat of categories(); track cat) {
<option [value]="cat">{{ cat }}</option>
}
</select>
<span class="result-count">
{{ filteredProducts().length }} Ergebnisse
</span>
</div>
@for (product of filteredProducts(); track product.id) {
<app-product-card [product]="product" />
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ProductFilterComponent {
private productService = inject(ProductService);
// State
searchTerm = signal('');
selectedCategory = signal('alle');
// Vom Service -- toSignal() konvertiert Observable zu Signal
products = toSignal(this.productService.getAll(), {
initialValue: [] as Product[],
});
// Abgeleitete Werte
categories = computed(() => {
const cats = this.products().map(p => p.category);
return ['alle', ...new Set(cats)];
});
filteredProducts = computed(() => {
let result = this.products();
const term = this.searchTerm().toLowerCase();
const cat = this.selectedCategory();
if (term) {
result = result.filter(p =>
p.name.toLowerCase().includes(term)
);
}
if (cat !== 'alle') {
result = result.filter(p => p.category === cat);
}
return result;
});
} Drei Dinge fallen auf. Erstens: Kein ngOnInit, kein ngOnDestroy, keine Subscriptions. Der gesamte State ist deklarativ. Zweitens: toSignal() brückt die Welt zwischen RxJS-Observables (vom Service) und Signals (im Component). Drittens: filteredProducts wird nur neu berechnet, wenn sich products, searchTerm oder selectedCategory tatsächlich ändern. Das ist präziser als jede manuelle Optimierung mit OnPush und ChangeDetectorRef.
Wann RxJS die bessere Wahl bleibt
Signals sind kein Allheilmittel. Es gibt klare Fälle, in denen RxJS überlegen ist:
HTTP-Requests. Angular’s HttpClient gibt Observables zurück. Das ist sinnvoll, weil HTTP-Requests stornierbar sein müssen (switchMap, takeUntil). Ein Signal kann nicht abgebrochen werden.
Debouncing und Throttling. Eine Sucheingabe mit debounceTime(300) und distinctUntilChanged() ist in RxJS ein Einzeiler. Mit Signals braucht man entweder einen manuellen Timer oder doch wieder RxJS.
Komplexe Event-Koordination. Wenn mehrere asynchrone Streams kombiniert werden müssen (combineLatest, merge, race), ist RxJS das richtige Werkzeug. Signals können das nicht abbilden.
WebSockets und Server-Sent Events. Langlebige Streams, die Werte über Zeit emittieren, sind das natürliche Habitat von Observables.
Die Brücke zwischen beiden Welten ist gut gebaut. toSignal() konvertiert ein Observable in ein Signal. toObservable() macht den umgekehrten Weg. Damit lassen sich beide Modelle sauber in einem Component kombinieren:
@Component({ /* ... */ })
export class SearchComponent {
// Signal für den Input-State
query = signal('');
// Signal -> Observable für RxJS-Operatoren
private query$ = toObservable(this.query);
// RxJS-Pipeline für Debouncing -> zurück als Signal
results = toSignal(
this.query$.pipe(
debounceTime(300),
distinctUntilChanged(),
filter(q => q.length >= 2),
switchMap(q => this.searchService.search(q)),
),
{ initialValue: [] as SearchResult[] }
);
// Computed Signal basierend auf dem Ergebnis
resultCount = computed(() => this.results().length);
hasResults = computed(() => this.resultCount() > 0);
} Das ist das Best-of-both-worlds-Pattern: Signals für den synchronen UI-State, RxJS für die asynchrone Pipeline, und toSignal()/toObservable() als saubere Brücke dazwischen.
Signal-basierte Inputs und die Zukunft
Ab Angular 17.1 sind Signal Inputs stabil. Das ändert fundamental, wie Components Daten empfangen:
// Klassisch: @Input Decorator
@Component({ /* ... */ })
export class UserCardComponent {
@Input() userId!: string;
@Input() showAvatar = true;
// Problem: ngOnChanges nötig, um auf Änderungen zu reagieren
ngOnChanges(changes: SimpleChanges) {
if (changes['userId']) {
this.loadUser(changes['userId'].currentValue);
}
}
}
// Modern: Signal Inputs
@Component({ /* ... */ })
export class UserCardComponent {
userId = input.required<string>();
showAvatar = input(true);
// Abgeleiteter State -- reagiert automatisch
user = toSignal(
toObservable(this.userId).pipe(
switchMap(id => this.userService.getById(id))
)
);
displayName = computed(() =>
this.user()?.name ?? 'Wird geladen...'
);
} Signal Inputs sind nicht optional — sie sind die Richtung, in die Angular geht. Die gesamte Architektur bewegt sich hin zu einem zoneless, signal-basierten Modell. ngOnChanges wird langfristig überflüssig. Change Detection wird vollständig auf Signals basieren.
Was schon jetzt funktioniert: provideExperimentalZonelessChangeDetection() in der App-Config. Damit entfällt Zone.js komplett — die Bundle-Größe sinkt, das Debugging wird einfacher, und die Performance steigt messbar. In Benchmarks sind zoneless Angular-Apps 20-30 Prozent schneller im Initial Render.
Migration: Pragmatisch statt dogmatisch
Eine Migration auf Signals muss nicht Big-Bang sein. Der pragmatische Weg:
Phase 1: Neue Components mit Signals. Jeder neue Component nutzt signal(), computed(), Signal Inputs. Bestehender Code bleibt unverändert.
Phase 2: Shared State refactoren. Services, die BehaviorSubject für synchronen State verwenden, auf Signals umstellen. Das betrifft typischerweise Theme-State, User-Session, UI-Toggles.
Phase 3: Template-Driven Migration. *ngIf und *ngFor durch @if und @for ersetzen. async Pipes durch toSignal() ersetzen. Das ist oft ein mechanischer Schritt.
Phase 4: Zoneless vorbereiten. Wenn alle Components auf Signals basieren, kann Zone.js entfernt werden. Das erfordert, dass kein Code mehr implizit auf Zone.js-basierte Change Detection angewiesen ist.
Der wichtigste Punkt: Nicht alles gleichzeitig migrieren. Signals und RxJS koexistieren hervorragend. Ein Component kann Signal Inputs haben und intern trotzdem RxJS verwenden. Die Brücken-APIs machen das schmerzfrei.
Fazit
Signals sind nicht die nächste Mode-Erscheinung im Frontend. Sie sind Angulars Antwort auf ein fundamentales Architekturproblem. Zone.js war ein genialer Hack — aber ein Hack. Signals sind die saubere Lösung.
Für den Alltag bedeutet das: Weniger Boilerplate, weniger Bugs durch vergessene Subscriptions, präzisere Change Detection, kleinere Bundles. Der Preis ist niedrig — die API ist minimal, die Lernkurve flach, und die Migration kann schrittweise erfolgen.
Wer heute ein neues Angular-Projekt startet, sollte Signals-first denken. Wer ein bestehendes Projekt hat, kann schrittweise migrieren, ohne den laufenden Betrieb zu gefährden. Das ist selten bei Framework-Änderungen dieser Größenordnung — und spricht für das durchdachte Design.