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.

ProblemSignalRxJS
Lokaler UI-ZustandErste WahlOverkill
Komponenten-InputsNative Signal-Inputs(alt)
HTTP-RequestsÜber resource()Erste Wahl (HttpClient)
Such-Debounce + CancelMit InteropErste Wahl (debounceTime etc.)
Router-Events / Form-valueChangesVia toSignalNative API
Combine mehrerer QuellencomputedcombineLatest/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.

TypeScript to-signal-basics.ts
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:

OptionTypWirkung
initialValueTWert, der zurückgegeben wird, bevor das Observable das erste Mal emittiert.
requireSynctrueWirft Runtime-Error, wenn das Observable nicht synchron emittiert.
manualCleanuptrueSchaltet Auto-Unsubscribe ab — du bist dann für destroyRef zuständig.
injectorInjectorWenn nicht im Injection Context, expliziten Injector mitgeben.
equalValueEqualityFn<T>Custom-Equality-Check, wie bei signal() und computed().
rejectErrorstrueErrors 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:

KonfigurationWert vor erster Emission
toSignal(obs$)undefined (Typ: T | undefined)
toSignal(obs$, &#123; initialValue: x &#125;)x
toSignal(obs$, &#123; requireSync: true &#125;)erste sync-Emission, sonst Error
Observable ist BehaviorSubject ohne Optionendirekt 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.

TypeScript require-sync.ts
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().

TypeScript to-observable-basics.ts
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.

TypeScript take-until-destroyed.ts
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:

TypeScript explicit-destroyref.ts
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.

TypeScript user-search.component.ts
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.

Aspektasync PipetoSignal()
Subscribe-ModellPro Template-Bind eine SubscriptionEine Subscription, Wert gecached im Signal
Mehrfache Reads im TemplateMehrfaches Subscribe / async-Pipe-AufrufeBeliebig oft, kostenlos
OnPush Change DetectionMarkiert Komponente bei EmissionFunktioniert ohne Zone.js
Zoneless-fähigEingeschränkt (braucht ChangeDetectorRef-Tricks)Ja, native Integration
Initial-Wertnull bis erste EmissioninitialValue oder requireSync
CleanupImplizit beim DestroyImplizit beim Destroy (Injection-Context)
TypingT | null (typisch)T, T | undefined oder mit requireSync strikt T

Entscheidungsmatrix für den Alltag

SzenarioEmpfehlung
Lokaler UI-Zustand (offen/zu, aktiver Tab, Filter)signal()
Abgeleiteter Wert aus anderem Signalcomputed()
Side-Effect bei Signal-Änderung (DOM, Logging)effect()
HTTP-Anfrage einmalig, ergebnisbasiertresource() / rxResource()
HTTP-Anfrage mit Operatoren-ChainRxJS + toSignal() für Template
Such-Debounce / Cancel / RetryRxJS, dann toSignal()
Form valueChanges als Signal verwendentoSignal(form.valueChanges)
Router-Events filtern und im Template anzeigentoSignal(router.events.pipe(filter(...)))
WebSocket-StreamRxJS, im Template toSignal()
Mehrere Streams kombinierenRxJS combineLatest + toSignal
Zwei Signals in einen Wert verbindencomputed()

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

/ Weiter

Zurück zu Signals

Zur Übersicht