Skip to content
Angular ng signals 4 min read

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: true on a source that does not emit synchronously, Angular throws NG0601 at construction time. Only use it for BehaviorSubject, startWith(...), or similarly seeded streams.

toSignal() options reference

OptionTypePurpose
initialValueTValue the signal returns before the first emission.
requireSyncbooleanAssert the source emits synchronously; narrows the type to T.
injectorInjectorProvide an injection context when not in a constructor/initializer.
manualCleanupbooleanKeep 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 prefer computed().
  • Call both functions in a constructor or field initializer so cleanup happens automatically; pass injector when you cannot.
  • Use requireSync: true for BehaviorSubject-style sources to avoid the undefined union; use initialValue for async sources.
  • Handle observable errors with catchError upstream, since toSignal() 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.
Last updated June 14, 2026
Was this helpful?