Signal & RxJS Interop
Signals and observables solve overlapping problems from opposite directions: signals are synchronous, pull-based, glitch-free values built for the template, while observables are push-based streams built for async events like HTTP, WebSockets, and user input over time. Real applications need both, so Angular ships an interop layer in @angular/core/rxjs-interop with two functions — toSignal() and toObservable() — that let you cross the boundary without manual subscription bookkeeping. This page shows how to convert in both directions safely.
Converting an observable to a signal
toSignal() subscribes to an Observable and returns a read-only signal that always holds the stream’s latest emitted value. The subscription is created immediately and torn down automatically when the surrounding injection context (usually a component) is destroyed, so you never write an ngOnDestroy or call unsubscribe().
import { Component, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { toSignal } from '@angular/core/rxjs-interop';
interface User { id: number; name: string; }
@Component({
selector: 'app-users',
standalone: true,
template: `
@if (users(); as list) {
<ul>
@for (u of list; track u.id) {
<li>{{ u.name }}</li>
}
</ul>
} @else {
<p>Loading…</p>
}
`,
})
export class UsersComponent {
private http = inject(HttpClient);
users = toSignal(this.http.get<User[]>('/api/users'));
}
Because toSignal() runs in a field initializer, it sits inside the component’s injection context and is cleaned up automatically. The signal reads undefined until the first value arrives — which is why the template’s @if handles the loading state.
Handling the initial value
Before the source emits, the signal needs something to return. You control that with options. By default the type is T | undefined. Provide initialValue for a typed seed, or set requireSync: true when the source emits synchronously on subscription (such as a BehaviorSubject), which narrows the type to plain T.
import { BehaviorSubject } from 'rxjs';
import { toSignal } from '@angular/core/rxjs-interop';
const source$ = new BehaviorSubject(0);
// Synchronous source — type is `number`, never `undefined`.
const count = toSignal(source$, { requireSync: true });
// Async source with an explicit seed — type is `number`.
const score = toSignal(asyncScore$, { initialValue: 0 });
If you set
requireSync: trueon a source that does not emit synchronously, Angular throwsNG0601at construction time. Only use it forBehaviorSubject,startWith(...), or similarly seeded streams.
toSignal() options reference
| Option | Type | Purpose |
|---|---|---|
initialValue | T | Value the signal returns before the first emission. |
requireSync | boolean | Assert the source emits synchronously; narrows the type to T. |
injector | Injector | Provide an injection context when not in a constructor/initializer. |
manualCleanup | boolean | Keep the subscription alive past the injection context’s destruction. |
Errors are not swallowed: if the observable emits an error, toSignal() rethrows it the next time the signal is read, so wrap fallible streams with catchError upstream.
Converting a signal to an observable
toObservable() does the reverse — it watches a signal and produces an Observable that emits whenever the signal’s value changes. Internally it uses an effect() to track the signal, so it must also run inside an injection context.
import { Component, signal, inject } from '@angular/core';
import { toObservable } from '@angular/core/rxjs-interop';
import { debounceTime, switchMap } from 'rxjs/operators';
import { HttpClient } from '@angular/common/http';
@Component({ selector: 'app-search', standalone: true, template: '' })
export class SearchComponent {
private http = inject(HttpClient);
query = signal('');
results = toObservable(this.query).pipe(
debounceTime(300),
switchMap((q) => this.http.get(`/api/search?q=${q}`)),
);
}
This pattern is the canonical reason to reach for toObservable(): signals have no time-based operators, but RxJS does. Convert the signal to a stream, apply debounceTime, switchMap, retry, and friends, then optionally convert back with toSignal() for the template.
The resulting observable emits the signal’s current value on subscription and then every subsequent change. Emission timing follows the effect scheduler, so multiple synchronous updates between change-detection runs collapse into a single emission of the final value.
A round-trip pattern
Combining both functions gives you a fully reactive pipeline that starts and ends in the signal world:
results = toSignal(
toObservable(this.query).pipe(
debounceTime(300),
switchMap((q) => this.http.get<string[]>(`/api/search?q=${q}`)),
),
{ initialValue: [] as string[] },
);
Output:
query="a" -> (debounced, no request yet)
query="an" -> (debounced)
query="ang" -> GET /api/search?q=ang -> ["Angular", "Angular Material"]
Best practices
- Reach for
toObservable()only when you genuinely need RxJS operators (debouncing, retries, combining streams); otherwise prefercomputed(). - Call both functions in a constructor or field initializer so cleanup happens automatically; pass
injectorwhen you cannot. - Use
requireSync: trueforBehaviorSubject-style sources to avoid theundefinedunion; useinitialValuefor async sources. - Handle observable errors with
catchErrorupstream, sincetoSignal()rethrows on read. - Wrap the round-trip output in
toSignal()so templates stay in the pull-based signal model with no manual subscriptions. - For HTTP-driven async state in modern Angular, consider the
resource()API before hand-rolling interop pipelines.