RxJS Interop with Signals
The bridge
Section titled “The bridge”@angular/core/rxjs-interop provides the four APIs that connect both worlds:
| API | Direction | Purpose |
|---|---|---|
toSignal() | Observable → Signal | Consume a stream as state |
toObservable() | Signal → Observable | Feed a signal into an RxJS pipeline |
takeUntilDestroyed() | — | Automatic unsubscription |
rxResource() | Observable → Resource | Async data with loading/error state |
toSignal()
Section titled “toSignal()”import { toSignal } from '@angular/core/rxjs-interop';
export class UserComponent { private readonly api = inject(UserApi);
readonly user = toSignal(this.api.currentUser$, { initialValue: null }); readonly displayName = computed(() => this.user()?.name ?? 'Guest');}The three initialisation modes
Section titled “The three initialisation modes”// 1. Explicit initial value — the signal type is User | nulltoSignal(source$, { initialValue: null });
// 2. Undefined until the first emission — type is User | undefinedtoSignal(source$);
// 3. Requires a synchronous first emission — type is User// Throws at runtime if the source does not emit immediatelytoSignal(source$, { requireSync: true });Error handling
Section titled “Error handling”By default, a source error is rethrown the next time the signal is read, which usually crashes the template. Handle errors in the pipeline:
readonly user = toSignal( this.api.currentUser$.pipe(catchError(() => of(null))), { initialValue: null },);Subscription lifetime
Section titled “Subscription lifetime”toSignal() subscribes immediately and unsubscribes when the injection context is destroyed. That means:
- Called in a field initializer of a component → tied to the component.
- Called in a
providedIn: 'root'service → lives for the whole app.
// Outside an injection context you must pass an injectortoSignal(source$, { injector: this.injector, initialValue: null });toObservable()
Section titled “toObservable()”Converts a signal into an observable so you can apply time-based operators.
import { toObservable } from '@angular/core/rxjs-interop';
export class SearchComponent { readonly term = signal('');
readonly results = toSignal( toObservable(this.term).pipe( debounceTime(300), distinctUntilChanged(), filter((t) => t.length >= 2), switchMap((t) => this.api.search(t).pipe(catchError(() => of([])))), ), { initialValue: [] }, );}This is the canonical typeahead in modern Angular: signal in, signal out, RxJS only for the timing in between.
rxResource() — the recommended default for data fetching
Section titled “rxResource() — the recommended default for data fetching”import { rxResource } from '@angular/core/rxjs-interop';
export class ProductListComponent { private readonly api = inject(ProductApi);
readonly categoryId = signal<string | undefined>(undefined);
readonly products = rxResource({ params: () => { const id = this.categoryId(); return id ? { id } : undefined; // undefined ⇒ request is skipped }, stream: ({ params }) => this.api.listByCategory(params.id), });}Template:
@if (products.isLoading()) { <app-spinner />} @else if (products.error(); as error) { <app-error [error]="error" (retry)="products.reload()" />} @else { @for (product of products.value() ?? []; track product.id) { <app-product-card [product]="product" /> }}What you get for free
Section titled “What you get for free”- Automatic request cancellation when
paramschange. isLoading(),error(),value(),status()as signals.reload()to refetch.set()/update()for optimistic local updates.- Skipping the request when
paramsreturnsundefined.
httpResource() for the simple case
Section titled “httpResource() for the simple case”readonly user = httpResource<User>(() => `/api/users/${this.userId()}`);No service, no pipeline — appropriate for straightforward reads.
Migration strategy
Section titled “Migration strategy”You do not need a big-bang rewrite. Migrate by layer, outside-in:
Step 1 — Templates
Section titled “Step 1 — Templates”Replace | async with signals. Zero risk, immediate readability gain.
// Beforereadonly users$ = this.api.getUsers();
// Afterreadonly users = toSignal(this.api.getUsers(), { initialValue: [] });Step 2 — Derived state
Section titled “Step 2 — Derived state”Replace combineLatest + map chains that have no time dimension with computed().
// Beforereadonly visible$ = combineLatest([this.users$, this.filter$]).pipe( map(([users, filter]) => users.filter((u) => u.role === filter)),);
// Afterreadonly visible = computed(() => this.users().filter((u) => u.role === this.filter()),);Step 3 — Data fetching
Section titled “Step 3 — Data fetching”Replace trigger-subject + switchMap facades with rxResource.
Step 4 — What stays in RxJS
Section titled “Step 4 — What stays in RxJS”Keep RxJS for:
- Debounce, throttle, retry with backoff.
- WebSockets, SSE, polling.
- Router events, form
valueChanges. - Anything where cancellation semantics matter.
Anti-patterns
Section titled “Anti-patterns”Converting back and forth without reason
Section titled “Converting back and forth without reason”// ❌ Pointless round tripreadonly total = toSignal( toObservable(this.items).pipe(map((i) => i.length)), { initialValue: 0 },);
// ✅readonly total = computed(() => this.items().length);Using effect() to copy one signal into another
Section titled “Using effect() to copy one signal into another”// ❌ Creates a second source of truth and risks loopseffect(() => this.filtered.set(this.items().filter(Boolean)));
// ✅readonly filtered = computed(() => this.items().filter(Boolean));Subscribing inside an effect
Section titled “Subscribing inside an effect”// ❌ Leaks a subscription on every runeffect(() => { this.api.load(this.id()).subscribe((d) => this.data.set(d));});
// ✅readonly data = rxResource({ params: () => ({ id: this.id() }), stream: ({ params }) => this.api.load(params.id),});Testing interop code
Section titled “Testing interop code”it('should expose the latest emitted user as a signal', () => { const subject = new BehaviorSubject<User | null>(null); TestBed.configureTestingModule({ providers: [{ provide: UserApi, useValue: { currentUser$: subject } }], });
const component = TestBed.createComponent(UserComponent).componentInstance; expect(component.displayName()).toBe('Guest');
subject.next({ id: '1', name: 'Ada' }); TestBed.tick(); // flush the effect expect(component.displayName()).toBe('Ada');});