Angular RxJS & Signal Interop: toSignal, toObservable, and rxResource [2026]

Link copied
Angular RxJS & Signal Interop: toSignal, toObservable, and rxResource [2026]

Angular RxJS & Signal Interop: toSignal, toObservable, and rxResource [2026]

Angular Tutorial › Module 7: RxJS › Lesson 7.3

Almost every real Angular app in 2026 has both signals and observables. Components read signals, HttpClient returns observables, the router emits observables, and a WebSocket or EventSource feed is an observable too. The code that converts between the two is small — usually one toSignal() or toObservable() call — but it is where a surprising number of bugs live: signals that read undefined, HTTP requests that fire twice, subscriptions that outlive their component, and events that silently disappear.

This is lesson 7.3 of the Angular Tutorial. Lesson 7.2 covered when RxJS is still the right tool, and lesson 3.7 introduced the two bridge functions. This lesson goes into the mechanics: exactly when each bridge subscribes, how it cleans up, what it does with errors and timing, and when rxResource is a better choice than hand-wiring both.

The interop surface at a glance #

Everything lives in @angular/core/rxjs-interop:

API Direction Subscribes Cleans up
toSignal(obs$) Observable → Signal Immediately, when called When the injection context's DestroyRef fires
toObservable(sig) Signal → Observable Per subscriber; reads the signal through an internal effect() When the effect's context is destroyed, or the subscriber unsubscribes
rxResource({ params, stream }) Signal params → Observable → resource signals When params produce a value Unsubscribes the previous stream on every params change and on destroy
outputFromObservable(obs$) Observable → component output When a parent listens With the component
takeUntilDestroyed() Operator — Completes the stream on destroy (lesson 7.4)

The first three are the subject of this lesson. outputFromObservable() was covered in lesson 2.5, and takeUntilDestroyed() gets its own lesson next.

toSignal() — what actually happens #

import { toSignal } from '@angular/core/rxjs-interop';

@Component({ /* ... */ })
export class Profile {
  private http = inject(HttpClient);
  user = toSignal(this.http.get<User>('/api/user'));   // Signal<User | undefined>
}

Four things happen on that line, and each one has a consequence.

1. It subscribes eagerly. The subscription starts the moment toSignal() runs — in this case, during construction — not when the template first reads user(). The async pipe, by contrast, subscribes when the view renders. For a cold HTTP observable, toSignal() sends the request even if the template hides the result behind an @if that is false.

2. The type includes undefined. An observable has no value until it emits, so the signal needs something to return in the meantime. Without options, that is undefined, and the type is Signal<User | undefined>. You have two ways to remove it:

// Provide a placeholder
count = toSignal(this.count$, { initialValue: 0 });          // Signal<number>

// Or promise that the source emits synchronously on subscribe
state = toSignal(this.store.state$, { requireSync: true });  // Signal<State>

requireSync is for sources such as BehaviorSubject, or pipes that start with startWith(). If the source does not emit during the subscribe call, toSignal() throws immediately — which is what you want, because it turns a silent undefined into a loud error at construction.

3. It needs an injection context. toSignal() looks up the current DestroyRef to know when to unsubscribe. Field initialisers and the constructor are injection contexts. ngOnInit, event handlers, and setTimeout callbacks are not, and calling it there throws NG0203. If you genuinely need to create the signal later, pass an injector:

private injector = inject(Injector);

ngOnInit() {
  this.data = toSignal(this.load$(this.id()), { injector: this.injector });
}

4. It unsubscribes when that context is destroyed. In a component, that is when the component is destroyed. In a service providedIn: 'root', that is when the application is destroyed — which, in the browser, is never.

Errors and completion #

If the observable errors, toSignal() stores the error and throws it every time the signal is read. In a template, that means the whole view fails to render. Handle errors before they reach the bridge:

user = toSignal(
  this.http.get<User>('/api/user').pipe(
    catchError(() => of(null)),
  ),
  { initialValue: null },
);

If the observable completes, the signal keeps returning the last value it received. That is the right behaviour for HTTP calls, which emit once and complete.

The remaining options are narrower. equal supplies a custom equality function so identical emissions don't notify consumers. manualCleanup: true skips the DestroyRef registration entirely; use it only for sources that complete on their own and when you are creating the signal outside any injection context.

toObservable() — a signal is not a stream #

import { toObservable } from '@angular/core/rxjs-interop';

query = signal('');
results$ = toObservable(this.query).pipe(
  debounceTime(300),
  switchMap(q => this.http.get<Result[]>(`/api/search?q=${q}`)),
);

Internally, toObservable() creates an effect() that reads the signal and pushes its value into a ReplaySubject. That implementation explains every behaviour worth knowing:

  • It needs an injection context, for the same reason as toSignal() — the effect has to be owned by something.
  • Values are coalesced. Effects run after the current synchronous work finishes, so if you call query.set('a'), query.set('ab'), query.set('abc') in one tick, subscribers see only 'abc'.
  • Emission is asynchronous after the first value. On subscribe, the current value may arrive synchronously; later changes arrive when the effect runs.
  • Late subscribers get the current value because of the replay.

Coalescing is correct for state, where only the latest value matters. It is wrong for events, where every occurrence matters. If you model clicks, messages, or "item added" notifications as a signal and convert it with toObservable(), you will lose events that happen in the same tick. Model events with a Subject or an output() from the start, and keep signals for state.

The round trip, and when to skip it #

The search example above usually ends with a conversion back:

results = toSignal(this.results$, { initialValue: [] });

Signal → observable → signal is a legitimate pattern when the middle needs operators signals do not have: debounceTime, switchMap cancellation, retry with backoff, scan. It is a smell when the middle is only map or filter:

// Unnecessary round trip
fullName = toSignal(toObservable(this.user).pipe(map(u => `${u.first} ${u.last}`)));

// Same result, synchronous, no subscription
fullName = computed(() => `${this.user().first} ${this.user().last}`);

computed() is synchronous, glitch-free, and has nothing to unsubscribe. Reach for the round trip only when an RxJS operator earns its place.

rxResource() — the round trip, packaged #

For the most common round trip — "when these signals change, run this observable and give me the latest result" — Angular ships a dedicated API:

import { rxResource } from '@angular/core/rxjs-interop';

export class Search {
  private http = inject(HttpClient);
  query = signal('');

  results = rxResource({
    params: () => ({ q: this.query() }),
    stream: ({ params }) =>
      this.http.get<Result[]>(`/api/search?q=${params.q}`),
  });
}
@if (results.isLoading()) { <p>Searching…</p> }
@if (results.error()) { <p>Search failed.</p> }
@for (r of results.value() ?? []; track r.id) { <li>{{ r.title }}</li> }

Compared with the hand-written version, you get:

  • Cancellation built in. When params change, the previous stream is unsubscribed — the same behaviour as switchMap.
  • Error state instead of a throwing signal. error() holds the failure; reading value() does not break the template.
  • Loading state and reload() without extra signals.
  • No injection-context surprises beyond the one-time creation, which normally happens in a field initialiser.

One rule to remember: the stream must emit a value or an error before it completes. An observable that completes without emitting leaves the resource with nothing to show.

What rxResource() does not do is debounce. If you need that, debounce the signal that feeds params — with the debounced() helper from lesson 3.4 — or keep the explicit toObservable().pipe(debounceTime()) version.

Which bridge, when #

You have You want Use
An observable (HTTP, router, store) To read it in a template or computed() toSignal()
A signal To feed an RxJS operator chain toObservable()
Signal inputs that should trigger an async fetch Value, loading, and error as signals rxResource() (or httpResource() for plain HTTP, lesson 6.3)
A signal A derived signal computed() — no bridge needed
An event source A component output outputFromObservable()

Leaks and other gotchas #

Symptom Cause Fix
NG0203 when calling toSignal() Called in ngOnInit, a handler, or a callback Move to a field initialiser, or pass { injector }
Template shows nothing, then pops in Signal is undefined until the first emission initialValue, requireSync, or an @if guard
The same HTTP request fires twice toSignal() and an async pipe both subscribe to the same cold observable Pick one consumer, or shareReplay(1) the source
Subscriptions pile up while the component lives toSignal() called inside a method that runs repeatedly (each call adds a subscription tied to the component's lifetime) Create the signal once; switch sources inside the observable with switchMap, or use rxResource()
Stream never stops toSignal() over an infinite source (interval, WebSocket) in a root service Scope the service to a component, or complete the source explicitly
Some events never arrive Events modelled as a signal and converted with toObservable() — coalesced Use a Subject or output() for events
Whole view breaks on a failed request Error thrown on signal read catchError before toSignal(), or use rxResource() and read error()

The "method that runs repeatedly" leak deserves a closer look, because the code looks innocent:

// Each call creates a new subscription that lives until the component dies
selectUser(id: string) {
  this.user = toSignal(this.api.user$(id), { injector: this.injector });
}

Clicking through twenty users leaves twenty live subscriptions. Put the changing input in a signal and let one pipeline react to it instead — which is exactly what rxResource() does for you.

What's next #

Lesson 7.4 closes Module 7 with takeUntilDestroyed() and DestroyRef — the modern way to clean up the subscriptions you still write by hand, and why the takeUntil(this.destroy$) pattern is no longer needed. After that, Module 8 moves to CSR performance: OnPush, @for tracking, NgOptimizedImage, and bundle analysis.

Try it yourself #

Take the search component above and build it twice: once with toObservable() → debounceTime → switchMap → toSignal(), and once with rxResource(). Open the network tab and type quickly. Then call query.set() three times in a row from a button handler and count how many requests each version makes.

If you get stuck on an injection-context error along the way, you can ask Claude with the Angular CLI MCP server connected:

YouI moved my toSignal() call into ngOnInit because it needs an input value, and now I get NG0203. What’s the right fix?
Claude · used search_documentationtoSignal() must run in an injection context so it can find a DestroyRef, and ngOnInit is not one. You have three options. (1) If you use signal inputs, you don’t need ngOnInit at all: keep the call in a field initialiser and make the source react to the input — rxResource({ params: () => this.id(), stream: ({ params }) => this.api.load(params) }). (2) Keep toSignal() in a field initialiser and build the source with toObservable(this.id).pipe(switchMap(id => this.api.load(id))). (3) As a last resort, inject Injector and pass { injector } — but only if the call runs once, or you will stack subscriptions. Option 1 is the idiomatic choice in current Angular.
Angular Tutorial · Lesson 7.3
← Previous lesson Angular RxJS in 2026: When Signals Aren’t Enough and Observables Win [2026] Next lesson → takeUntilDestroyed() and DestroyRef (coming soon)

Up next in Angular

More from this topic

View all Angular articles →

Enjoyed this article?

Get new Angular tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email stays private. Required fields are marked *

Leave a Comment

Your email stays private. Required fields are marked *