Angular hat mit den Signals eine eigene, fein-granulare Reaktivitätsschicht eingeführt — RxJS bleibt aber das mächtigste Werkzeug für Streams, Operatoren-Pipelines und alles, was über die Zeit fließt: HTTP-Requests, Form-Eingaben, WebSockets, Router-Events. In echten Anwendungen brauchst du beides nebeneinander.
Die offizielle Brücke heißt RxJS-Interop und liefert seit Angular 16 drei Kern-APIs: toSignal() macht aus einem Observable einen Signal, toObservable() aus einem Signal ein Observable, und takeUntilDestroyed() ersetzt das alte ngOnDestroy-Boilerplate durch einen kurzen, zoneless-tauglichen Operator. Dieser Artikel zeigt dir die Signaturen, Edge-Cases, idiomatische Patterns und eine vollständige Such-Pipeline, die beide Welten verbindet.
Zwei Welten, ein Komponentenmodell
Signals und RxJS lösen unterschiedliche Probleme. Signals sind die richtige Wahl für lokalen Zustand: ein Counter, ein offener Tab, ein eingeklappter Bereich, der aktuelle Filterwert. Sie sind synchron, fein-granular, von Angulars Change Detection direkt verstanden und arbeiten ohne Subscription-Lifecycle. Inputs, Outputs und Model-Inputs sind selbst Signals — die Komponentengrenze spricht ab Angular 17/18 nativ Signal.
RxJS dagegen glänzt überall dort, wo es um Zeit, Streams und Komposition geht: HTTP-Antworten, debounceTime auf Such-Inputs, switchMap auf abhängige Requests, mergeMap auf parallele Uploads, retryWhen-Pipelines mit Backoff, WebSocket-Streams. Diese Probleme lassen sich mit Signals zwar emulieren, aber sehr umständlich — RxJS hat dafür ein gewachsenes, ausdrucksstarkes Operator-Vokabular.
In der Praxis hast du fast immer beides gleichzeitig: Ein User tippt in ein Input (Signal), das wird in einen Stream umgewandelt (toObservable), durch debounce/switchMap geschickt (RxJS) und das Ergebnis als Signal ins Template gebracht (toSignal). Genau dafür gibt es die Interop-APIs.
| Problem | Signal | RxJS |
|---|---|---|
| Lokaler UI-Zustand | Erste Wahl | Overkill |
| Komponenten-Inputs | Native Signal-Inputs | (alt) |
| HTTP-Requests | Über resource() | Erste Wahl (HttpClient) |
| Such-Debounce + Cancel | Mit Interop | Erste Wahl (debounceTime etc.) |
Router-Events / Form-valueChanges | Via toSignal | Native API |
| Combine mehrerer Quellen | computed | combineLatest/merge |
Die Funktion und ihre Optionen
toSignal() aus @angular/core/rxjs-interop nimmt ein Observable und gibt einen Signal zurück, der bei jeder Emission aktualisiert wird. Die Funktion subscribed im aktuellen Injection Context und unsubscribed automatisch beim Destroy — du musst dich um nichts kümmern.
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { Router, NavigationEnd } from '@angular/router';
import { filter, map } from 'rxjs/operators';
@Component({ selector: 'app-route-title', standalone: true, template: `
<p>Aktuelle Route: {{ url() }}</p>
` })
export class RouteTitleComponent {
private router = inject(Router);
// Observable -> Signal, mit initialValue weil router.events nicht sync emittiert
url = toSignal(
this.router.events.pipe(
filter((e): e is NavigationEnd => e instanceof NavigationEnd),
map(e => e.urlAfterRedirects),
),
{ initialValue: '/' },
);
}Die wichtigsten Optionen
toSignal() hat mehrere Overloads, die sich anhand des Options-Objekts unterscheiden:
| Option | Typ | Wirkung |
|---|---|---|
initialValue | T | Wert, der zurückgegeben wird, bevor das Observable das erste Mal emittiert. |
requireSync | true | Wirft Runtime-Error, wenn das Observable nicht synchron emittiert. |
manualCleanup | true | Schaltet Auto-Unsubscribe ab — du bist dann für destroyRef zuständig. |
injector | Injector | Wenn nicht im Injection Context, expliziten Injector mitgeben. |
equal | ValueEqualityFn<T> | Custom-Equality-Check, wie bei signal() und computed(). |
rejectErrors | true | Errors werden im Signal nicht geworfen, sondern verworfen. |
Was, wenn vor der ersten Emission gelesen wird?
Wird das Signal gelesen, bevor das Observable etwas emittiert hat, gibt toSignal() einen Default zurück — und der hängt von deinen Optionen ab:
| Konfiguration | Wert vor erster Emission |
|---|---|
toSignal(obs$) | undefined (Typ: T | undefined) |
toSignal(obs$, { initialValue: x }) | x |
toSignal(obs$, { requireSync: true }) | erste sync-Emission, sonst Error |
Observable ist BehaviorSubject ohne Optionen | direkt der gepufferte Wert (sync) |
requireSync: true ist die richtige Wahl, wenn du sicher bist, dass die Quelle synchron emittiert (BehaviorSubject, ReplaySubject mit Wert, of(...)). Der Vorteil: Der Signal-Typ ist strikt T statt T | undefined, du brauchst keine ?-Checks im Template.
import { BehaviorSubject } from 'rxjs';
import { toSignal } from '@angular/core/rxjs-interop';
const counter$ = new BehaviorSubject(0);
// counter ist Signal<number> — ohne | undefined
const counter = toSignal(counter$, { requireSync: true });
console.log(counter()); // 0 (sync gelesen, kein Default nötig)Errors im Observable
Wirft das Observable einen Fehler, propagiert toSignal() ihn standardmäßig beim nächsten Lesen des Signals — d. h. dein Template-Render kracht. Wenn du Errors lieber selbst handhaben willst, fängst du sie schon in der RxJS-Pipeline mit catchError ab und mappst sie auf einen Result-Wert. Alternativ schaltet rejectErrors: true das Werfen ab.
Wenn du in Operatoren-Land willst
Manchmal hast du einen Signal-Wert (z. B. einen Such-Input, der über model() gebunden ist), willst aber die volle Operatoren-Power von RxJS nutzen — debounce, distinct, switchMap auf einen HTTP-Endpoint. Dafür gibt es toObservable().
import { Component, inject, signal } from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';
@Component({ selector: 'app-search', standalone: true, template: `
<input [value]="query()" (input)="query.set($any($event.target).value)" />
@if (results(); as r) {
<ul>
@for (hit of r; track hit.id) { <li>{{ hit.title }}</li> }
</ul>
}
` })
export class SearchComponent {
private http = inject(HttpClient);
query = signal('');
// Signal -> Observable: bei jeder Signal-Aenderung emittiert es neu
private results$ = toObservable(this.query).pipe(
debounceTime(250),
distinctUntilChanged(),
switchMap(q => q
? this.http.get<{ id: string; title: string }[]>(`/api/search?q=${q}`)
: Promise.resolve([])
),
);
// Observable wieder zu Signal fuer das Template
results = toSignal(this.results$, { initialValue: [] });
}toObservable() produziert ein kaltes Observable: Bei jedem subscribe liest es das Signal und folgt dann den Änderungen, bis unsubscribed wird. Mehrere Subscriber lesen alle dasselbe Signal — kein Memoization-Problem, weil das Signal selbst der Cache ist.
Schluss mit ngOnDestroy + Subject
Vor Angular 16 sah Cleanup für Subscriptions so aus: Komponente bekommt ein private destroy$ = new Subject<void>(), jede Subscription wird mit .pipe(takeUntil(this.destroy$)) abgeschlossen, und in ngOnDestroy() ruft man this.destroy$.next(); this.destroy$.complete();. Vier Stellen Boilerplate für jede Komponente.
takeUntilDestroyed() ersetzt das durch einen Operator, der intern den DestroyRef der aktuellen Komponente nutzt — kein Subject, kein Lifecycle-Hook, kein Boilerplate.
import { Component, inject, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { interval } from 'rxjs';
@Component({ selector: 'app-clock', standalone: true, template: `
<p>Tick: {{ tick }}</p>
` })
export class ClockComponent {
tick = 0;
constructor() {
// Im Konstruktor: Injection Context vorhanden, kein DestroyRef noetig
interval(1000)
.pipe(takeUntilDestroyed())
.subscribe(n => this.tick = n);
}
}Außerhalb des Injection Contexts
Wenn du takeUntilDestroyed() in einer Methode (nicht im Konstruktor / Field-Initializer) verwendest, ist kein Injection Context aktiv — du musst dann den DestroyRef explizit übergeben:
import { Component, inject, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { fromEvent } from 'rxjs';
@Component({ selector: 'app-listener', standalone: true, template: `<p>Klicke irgendwo</p>` })
export class ListenerComponent {
private destroyRef = inject(DestroyRef);
startListening() {
fromEvent(document, 'click')
// Methode -> kein Injection Context -> destroyRef explizit
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => console.log('click'));
}
}Komplette Pipeline von Eingabe bis Ergebnis
Das folgende Beispiel zeigt eine produktionsnahe Such-Komponente: Input ist ein Signal, fließt als Observable durch Debounce/Distinct/Switch-Map zu einem HTTP-Endpoint, das Ergebnis kommt als Signal zurück ins Template.
import { Component, computed, inject, signal } from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
import { catchError, debounceTime, distinctUntilChanged, map, of, startWith, switchMap } from 'rxjs';
interface User { id: string; name: string; email: string; }
type State =
| { kind: 'idle' }
| { kind: 'loading' }
| { kind: 'ok'; users: User[] }
| { kind: 'error'; msg: string };
@Component({ selector: 'app-user-search', standalone: true, template: `
<input
placeholder="Name suchen ..."
[value]="query()"
(input)="query.set($any($event.target).value)" />
@switch (state().kind) {
@case ('loading') { <p>Lade ...</p> }
@case ('error') { <p class="error">{{ asError(state()).msg }}</p> }
@case ('ok') {
<ul>
@for (u of asOk(state()).users; track u.id) {
<li>{{ u.name }} <small>{{ u.email }}</small></li>
} @empty {
<li class="muted">Keine Treffer</li>
}
</ul>
}
@default { <p class="muted">Tippe einen Namen ein</p> }
}
` })
export class UserSearchComponent {
private http = inject(HttpClient);
query = signal('');
private state$ = toObservable(this.query).pipe(
debounceTime(250),
distinctUntilChanged(),
switchMap<string, ReturnType<typeof this.search>>(q =>
q.trim().length === 0 ? of({ kind: 'idle' } as State) : this.search(q)
),
);
state = toSignal(this.state$, { initialValue: { kind: 'idle' } as State });
private search(q: string) {
return this.http.get<User[]>(`/api/users?q=${encodeURIComponent(q)}`).pipe(
map(users => ({ kind: 'ok', users } as State)),
catchError(err => of({ kind: 'error', msg: err.message } as State)),
startWith({ kind: 'loading' } as State),
);
}
asError(s: State) { return s as Extract<State, { kind: 'error' }>; }
asOk(s: State) { return s as Extract<State, { kind: 'ok' }>; }
}Was hier wichtig ist: Der state ist ein Discriminated Union. Du modellierst „loading”, „error”, „ok”, „idle” als gleichberechtigte Zustände — das Template arbeitet mit @switch und der Compiler narrowt jeden Zweig korrekt. Klassisches loading$ | async-Boilerplate über drei Variablen entfällt.
Beide laufen. Wann nimmt man was?
Die async-Pipe ist seit Angular 2 das Mittel der Wahl, um Observables im Template zu rendern. Sie funktioniert weiterhin tadellos. toSignal() ist die signal-zentrische Alternative — und in zwei Szenarien eindeutig vorzuziehen: bei OnPush-Komponenten mit häufigen Reads desselben Werts und in zoneless Apps.
| Aspekt | async Pipe | toSignal() |
|---|---|---|
| Subscribe-Modell | Pro Template-Bind eine Subscription | Eine Subscription, Wert gecached im Signal |
| Mehrfache Reads im Template | Mehrfaches Subscribe / async-Pipe-Aufrufe | Beliebig oft, kostenlos |
| OnPush Change Detection | Markiert Komponente bei Emission | Funktioniert ohne Zone.js |
| Zoneless-fähig | Eingeschränkt (braucht ChangeDetectorRef-Tricks) | Ja, native Integration |
| Initial-Wert | null bis erste Emission | initialValue oder requireSync |
| Cleanup | Implizit beim Destroy | Implizit beim Destroy (Injection-Context) |
| Typing | T | null (typisch) | T, T | undefined oder mit requireSync strikt T |
Entscheidungsmatrix für den Alltag
| Szenario | Empfehlung |
|---|---|
| Lokaler UI-Zustand (offen/zu, aktiver Tab, Filter) | signal() |
| Abgeleiteter Wert aus anderem Signal | computed() |
| Side-Effect bei Signal-Änderung (DOM, Logging) | effect() |
| HTTP-Anfrage einmalig, ergebnisbasiert | resource() / rxResource() |
| HTTP-Anfrage mit Operatoren-Chain | RxJS + toSignal() für Template |
| Such-Debounce / Cancel / Retry | RxJS, dann toSignal() |
Form valueChanges als Signal verwenden | toSignal(form.valueChanges) |
| Router-Events filtern und im Template anzeigen | toSignal(router.events.pipe(filter(...))) |
| WebSocket-Stream | RxJS, im Template toSignal() |
| Mehrere Streams kombinieren | RxJS combineLatest + toSignal |
| Zwei Signals in einen Wert verbinden | computed() |
Die Faustregel: Solange dein Problem synchron und punktuell ist, bleib bei Signals. Sobald du Operatoren brauchst, die mit Zeit, Cancel oder Komposition mehrerer Streams arbeiten, wechsle zu RxJS — und trag das Ergebnis am Schluss mit toSignal() zurück ins Template.
Besonderheiten
toSignal braucht initialValue, requireSync oder ein sync-Observable
Sonst ist der Signal-Typ T | undefined und der erste Render-Tick liefert undefined. Bei BehaviorSubject oder ReplaySubject mit Initialwert kannst du requireSync: true nutzen — der Compiler garantiert dann den strikten Typ T ohne | undefined.
takeUntilDestroyed ist zoneless-safe
Funktioniert ohne Zone.js, weil es am DestroyRef-Lifecycle hängt, nicht am Change-Detection-Zyklus. Damit ist es das richtige Cleanup-Pattern für SSR und neue Zoneless-Apps — und es ersetzt zuverlässig das alte destroy$-Subject-Boilerplate.
toObservable produziert ein cold Observable
Bei jedem subscribe wird das Signal neu gelesen und ein effect intern aufgesetzt. Mehrere Subscriber lesen alle dasselbe Signal — kein Memoization-Problem, weil das Signal selbst der Cache ist. Aber: Der erste Wert kommt nicht synchron, sondern beim nächsten Microtask.
requireSync wirft Runtime-Error bei nicht-synchroner Quelle
toSignal(obs$, { requireSync: true }) wirft beim Erstellen sofort einen Error, wenn das Observable nicht synchron emittiert. Ideal für BehaviorSubject-Streams, bei denen der Wert garantiert vorhanden ist — und du sparst dir den | undefined-Tail im Typ.
DestroyRef.onDestroy() statt ngOnDestroy
Modern: inject(DestroyRef).onDestroy(() => cleanup()) in Functions, Provide-Factories und überall, wo es keine Klassen-Instance gibt. takeUntilDestroyed() baut intern darauf auf. Damit funktioniert Cleanup auch in Standalone-Function-Providern.
async pipe re-subscribed pro Template-Bind
Wenn du (user$ | async)?.name und (user$ | async)?.email nebeneinander schreibst, sind das zwei Subscriptions auf dasselbe Observable. toSignal() hat genau eine — der Wert ist im Signal gecached, jeder weitere Read kostet nichts.
rejectErrors verhindert Template-Crashes
Mit { rejectErrors: true } werden Errors im Source-Observable nicht ans Signal weitergereicht. Stattdessen behält das Signal seinen letzten Wert. Sinnvoll für UI-Streams, bei denen ein einzelner Fehler nicht die ganze Komponente killen darf — Error-Handling dann eher in der Pipeline mit catchError.
Injection Context oder expliziter Injector
toSignal(), toObservable() und takeUntilDestroyed() brauchen einen Injection Context: Konstruktor, Field-Initializer, inject()-Aufruf. In einer normalen Methode mussst du entweder injector/destroyRef explizit übergeben oder das ganze in runInInjectionContext() wrappen.
Weiterführende Ressourcen
Externe Quellen
- RxJS Interop Guide – Angular.dev
- toSignal API – Angular.dev
- toObservable API – Angular.dev
- takeUntilDestroyed API – Angular.dev
- DestroyRef API – Angular.dev