Einleitung: die Reaktivitäts-Revolution in Angular
Seit den ersten Angular-Versionen war RxJS der Motor der Reaktivität: HTTP-Aufrufe, Formulare, Router und Events liefen alle über Observables. Ein äußerst mächtiges, aber anspruchsvolles Werkzeug: Um einen einfachen abgeleiteten Wert anzuzeigen, brauchte man Subjects, Operatoren und Subscriptions, die wieder beendet werden mussten.
Mit Signals hat das Angular-Team ein einfacheres Modell für den Anwendungs-State eingeführt: ein Wert, den man liest und aktualisiert und von dem andere Werte automatisch abhängen. Das Ergebnis: weniger Code, bessere Lesbarkeit und die Grundlage für ein Angular ohne Zone.js. In diesem Artikel sehen wir uns an, wie und wann man State von RxJS auf Signals umstellt – mit einem vollständigen Refactoring.
RxJS vs. Signals: zwei Werkzeuge für unterschiedliche Probleme
Die nützlichste Unterscheidung lautet: RxJS beschreibt Ereignisse über die Zeit – Klicks, WebSocket-Nachrichten, HTTP-Antworten, Tastenanschläge, die gedrosselt werden sollen –, während Signals den aktuellen Zustand beschreiben und die daraus abgeleiteten Werte. Sie konkurrieren nicht: Sie lösen unterschiedliche Probleme, wie ich ausführlicher in diesem Artikel erklärt habe.
| RxJS | Signals | Hinweise |
|---|---|---|
BehaviorSubject<T> | signal<T>() | Aktueller Wert, synchron lesbar |
combineLatest() + map() | computed() | Abhängigkeiten werden automatisch verfolgt |
.subscribe() für Seiteneffekte | effect() | Nur nach außen |
async-Pipe | wert() im Template lesen | Keine Subscriptions zu verwalten |
debounceTime, switchMap | bleiben bei RxJS | Brücke über toObservable |
Auch bei der Change Detection gibt es einen Vorteil: Liest ein Template ein Signal, weiß Angular genau, welche Komponenten von diesem Wert abhängen, und aktualisiert nur diese – statt bei jedem Ereignis den gesamten Komponentenbaum erneut zu prüfen.
Anatomie des Refactorings: ein realer Fall
Das Szenario ist ein Klassiker: ein Dashboard mit Produktliste, einem Textsuchfeld und der Anzahl der gefilterten Einträge. Hier die „Old-School“-Version mit RxJS:
@Component({ /* ... */ })
export class ProductDashboardComponent implements OnInit, OnDestroy {
private readonly products$ = new BehaviorSubject<Product[]>([]);
private readonly filter$ = new BehaviorSubject<string>('');
private readonly destroy$ = new Subject<void>();
visible: Product[] = [];
count = 0;
constructor(private api: ProductApi) {}
ngOnInit(): void {
this.api.list().pipe(takeUntil(this.destroy$))
.subscribe(list => this.products$.next(list));
combineLatest([this.products$, this.filter$.pipe(debounceTime(300))])
.pipe(
map(([list, term]) =>
list.filter(p => p.name.toLowerCase().includes(term.toLowerCase()))),
takeUntil(this.destroy$),
)
.subscribe(visible => {
this.visible = visible;
this.count = visible.length;
});
}
onFilter(term: string): void { this.filter$.next(term); }
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}
Das funktioniert, hat aber die typischen Schwächen dieses Ansatzes:
- Viel Boilerplate: zwei Subjects, ein Destroy-Subject, zwei Subscriptions und ein
ngOnDestroy. - Ein schwer nachvollziehbarer Fluss: Um zu verstehen, woher
countkommt, muss man die ganze Operatorkette lesen. - Gefahr von Memory Leaks: Ein vergessenes
takeUntilgenügt, und eine Subscription lebt nach der Zerstörung der Komponente weiter. - Doppelter State:
visibleundcountsind Kopien, die man von Hand synchron halten muss.
Schritt für Schritt: State mit Signals
Schritt 1: Werte und Inputs werden zu Signals
Jedes BehaviorSubject, das State repräsentiert, wird zu einem Writable Signal. Aktualisiert wird es mit .set(), wenn man den Wert ersetzt, und mit .update(), wenn der neue Wert vom vorherigen abhängt:
readonly products = signal<Product[]>([]);
readonly filter = signal('');
onFilter(term: string): void {
this.filter.set(term); // replace the value
}
addProduct(p: Product): void {
this.products.update(list => [...list, p]); // derive from the previous one
}
Schritt 2: Abgeleitete Logik wird zu computed
Aus combineLatest mit map wird ein computed(). Abhängigkeiten werden nicht deklariert: Angular erkennt sie, indem es verfolgt, welche Signals gelesen werden. Außerdem ist ein computed lazy und memoisiert: Es wird nur neu berechnet, wenn sich eine Abhängigkeit ändert – und nur, wenn jemand es liest.
readonly visible = computed(() => {
const term = this.filter().toLowerCase();
return this.products().filter(p => p.name.toLowerCase().includes(term));
});
readonly count = computed(() => this.visible().length);
// Template: no async pipe, no subscriptions
// @for (p of visible(); track p.id) { <app-product-card [product]="p" /> }
// <span>{{ count() }} products</span>
visible und count sind keine zu synchronisierenden Kopien mehr: Sie sind per Konstruktion immer konsistent.
Schritt 3: Seiteneffekte mit effect
effect() dient der Kommunikation mit der Außenwelt: in den localStorage speichern, ein Log schreiben, eine Drittanbieter-Bibliothek wie ein Diagramm oder eine Karte synchronisieren. Es dient dagegen nicht dazu, zwei Signals abzugleichen: Dafür ist die Antwort fast immer ein computed.
constructor() {
// OK: a side effect towards the outside world
effect(() => localStorage.setItem('dashboard.filter', this.filter()));
}
// Anti-pattern: an effect that copies one signal into another
effect(() => this.total.set(this.visible().length));
// Better: derive it, and let Angular keep it in sync
readonly total = computed(() => this.visible().length);
Die Brücke zwischen beiden Welten: rxjs-interop
Aufrufe ans Backend – eine REST- oder NestJS-API – bleiben ganz natürlich Observables: HttpClient liefert Streams, und genau dort braucht man Abbruch, Wiederholungen und Zeitoperatoren. Das Paket @angular/core/rxjs-interop bietet zwei Brücken:
toSignal(): wandelt einen Stream, etwa eine HTTP-Antwort, in ein Signal um, das man direkt im Template nutzt.toObservable(): wandelt ein Signal in einen Stream um, wenn man Zeitoperatoren wiedebounceTimebraucht.
Hier eine Echtzeitsuche, die beides nutzt: Das Suchfeld ist ein Signal, Debounce und Abbruch der Anfragen bleiben bei RxJS, und das Ergebnis geht als Signal zurück ins Template. Beide Funktionen müssen in einem Injection-Kontext aufgerufen werden, zum Beispiel als Feldinitialisierer.
private readonly api = inject(ProductApi);
readonly query = signal('');
// signal → Observable (time operators) → signal (template)
readonly results = toSignal(
toObservable(this.query).pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(q => this.api.search(q)),
),
{ initialValue: [] as Product[] },
);
// Template: <input (input)="query.set($any($event.target).value)" />
// @for (p of results(); track p.id) { ... }
Architekturmuster: ein Signal-basierter Store
Wird der State von mehreren Komponenten gebraucht, wandert die Logik in einen Service. Die Regel lautet kapseln: Das beschreibbare Signal bleibt privat, nach außen wird mit .asReadonly() eine schreibgeschützte Version bereitgestellt, und Änderungen laufen ausschließlich über die Methoden des Service.
@Injectable({ providedIn: 'root' })
export class CartStore {
private readonly _items = signal<CartItem[]>([]);
readonly items = this._items.asReadonly(); // read-only from outside
readonly total = computed(() =>
this._items().reduce((sum, i) => sum + i.price * i.qty, 0));
add(item: CartItem): void { this._items.update(list => [...list, item]); }
clear(): void { this._items.set([]); }
}
Für größere Anwendungen bringt der Signal Store aus @ngrx/signals dasselbe Modell auf die globale Ebene, mit einer Standardstruktur für State, abgeleitete Werte und Methoden.
Fazit und Checkliste
- Signals für synchronen State und die UI: aktuelle Werte, Filter, Auswahlen, Zählungen.
- RxJS für asynchrone Ereignisse und zeitliche Abläufe: HTTP, WebSockets, Debounce, Abbruch. RxJS-Operatoren bleiben wertvoll.
computedfür alles Abgeleitete – nie eineffect, das Werte kopiert.- Beide Welten mit rxjs-interop verbinden, statt alles neu zu schreiben.
- Eine Komponente nach der anderen migrieren, beginnend mit denen, die die meisten manuellen Subscriptions haben.
Hast du schon Teile deiner Anwendung auf Signals umgestellt oder bist unsicher, wo die Grenze zu RxJS verlaufen soll? Hinterlasse unten einen Kommentar mit deinen Erfahrungen: Die interessantesten Fragen werden zu Ideen für die nächsten Artikel.
