DEV Community

Dominik Paszek
Dominik Paszek

Posted on

toObservable() — How to Bridge Angular Signals and RxJS

toObservable() — Bridging the Gap Between Angular Signals and RxJS

If you've been working with Angular Signals, you've probably hit this wall at some point.

You have a Signal — maybe a search input, maybe a selected filter from a dropdown — and you need to debounce it. Or switchMap it. Or pipe it through a chain of RxJS operators before sending it to an API.

And then you realise: Signals don't have debounceTime. They don't have switchMap. They live in a completely different reactive world.

That's where toObservable() comes in. It's the reverse bridge — and once you understand how it works, the combination of Signals and RxJS becomes genuinely powerful.


The Context: Two Reactive Worlds

Angular 16+ ships with two reactive primitives that serve different purposes:

Signals — synchronous, pull-based, zero boilerplate. Perfect for local component state and template bindings. No subscriptions, no cleanup, no async pipe.

Observables (RxJS) — asynchronous, push-based, operator-rich. Perfect for HTTP calls, debouncing, stream composition, and anything time-based.

The problem is that these two worlds don't talk to each other natively. That's why Angular ships @angular/core/rxjs-interop — a dedicated interop layer with two functions:

Observable ──── toSignal() ────▶ Signal
Signal     ──── toObservable() ──▶ Observable
Enter fullscreen mode Exit fullscreen mode

In a previous article on toSignal(), we covered the first direction. This one is about the second.


What toObservable() Actually Does

At its core, toObservable() wraps a Signal in an Observable. Every time the Signal's value changes, the Observable emits that new value.

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

const searchQuery = signal('');
const searchQuery$ = toObservable(searchQuery);

// searchQuery$ now emits every time searchQuery changes
Enter fullscreen mode Exit fullscreen mode

Under the hood, it uses Angular's effect() primitive — the same reactive context that tracks Signal reads. When the Signal changes, effect() fires, and the Observable emits.

This has one important implication worth knowing upfront.


The Async Timing Gotcha

Because toObservable() uses effect() internally, and effects always run asynchronously (in the next microtask), the Observable does not emit synchronously.

signal.set('new value')
       ↓
  NOT emitted synchronously
       ↓
  emitted in next microtask (async)
Enter fullscreen mode Exit fullscreen mode

Why asynchronous? Because effect() is scheduled asynchronously by design — it protects against infinite reactive loops where a signal change triggers an effect that changes a signal that triggers an effect...

For 99% of real-world use cases — UI bindings, HTTP calls, debounced search — this doesn't matter at all. The timing is imperceptible to users.

Where it does matter: synchronous unit tests. If you set a signal value and immediately expect the Observable to have emitted, your test will fail. You'll need to flush microtasks (e.g. with fakeAsync + flushMicrotasks() in Angular's testing utilities).

Keep that in the back of your head and you'll never be surprised by it.


The Real Power: Combining Both Directions

Here's where things get interesting. The most common pattern isn't just converting a Signal to an Observable — it's using both conversion functions together to keep your component template completely free of RxJS concepts.

The pattern looks like this:

results = toSignal(
  toObservable(someSignal).pipe(
    // full RxJS pipeline here
  ),
  { initialValue: [] }
);
Enter fullscreen mode Exit fullscreen mode

Signal in. Observable pipeline in the middle. Signal out. Your template never touches an Observable.

Let's build something real.


Real Example: Search with Debounce

The classic use case. A search input bound to a Signal, debounced, piped through an HTTP call, result exposed as a Signal.

The Component

import { Component, inject, signal } from '@angular/core';
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';
import { of } from 'rxjs';
import { UserService } from './user.service';
import { UserData } from './user.model';

@Component({
  selector: 'app-user',
  imports: [],
  templateUrl: './user.html',
})
export class UserComponent {
  private readonly userService = inject(UserService);

  protected searchQuery = signal('');

  protected results = toSignal(
    toObservable(this.searchQuery).pipe(
      debounceTime(300),
      distinctUntilChanged(),
      switchMap(query =>
        query
          ? this.userService.searchUsers(query)
          : of([])
      )
    ),
    { initialValue: [] as UserData[] }
  );
}
Enter fullscreen mode Exit fullscreen mode

The Template

<input
  [value]="searchQuery()"
  (input)="searchQuery.set($event.target.value)"
/>

@for (user of results(); track user.id) {
  <p>{{ user.name }}</p>
}
Enter fullscreen mode Exit fullscreen mode

The Service

searchUsers(query: string): Observable<UserData[]> {
  return of(MOCK_USERS.filter(u =>
    u.name.toLowerCase().includes(query.toLowerCase())
  )).pipe(delay(500));
}
Enter fullscreen mode Exit fullscreen mode

Take a moment to look at what this component doesn't have:

  • No ngOnInit
  • No subscribe()
  • No unsubscribe() or takeUntilDestroyed
  • No async pipe in the template
  • No BehaviorSubject

Two signals, one pipeline. The RxJS operators do exactly what they're good at — time-based stream manipulation — while Signals handle the template binding cleanly.

The query ? ... : of([]) check is a small but important production detail: if the search field is empty, return an empty array immediately instead of firing an API call.


Why Not Just Use a BehaviorSubject?

Fair question. The traditional approach using a BehaviorSubject as the source works perfectly well:

protected search$ = new BehaviorSubject<string>('');

protected results$ = this.search$.pipe(
  debounceTime(300),
  distinctUntilChanged(),
  switchMap(query => ...)
);
Enter fullscreen mode Exit fullscreen mode

And in the template:

<input (input)="search$.next($event.target.value)" />

@for (user of results$ | async; track user.id) { ... }
Enter fullscreen mode Exit fullscreen mode

This is battle-tested and there's nothing wrong with it. But notice what leaks into the template: the async pipe. In a component with multiple streams, you start stacking async pipes everywhere. The template starts to know too much about the reactive mechanics underneath.

The Signal approach keeps all of that inside the component class where it belongs. The template stays declarative and clean.


Injection Context Requirement

Like toSignal(), toObservable() must be called within an injection context. In practice this means:

In a field initializer (most common):

protected results = toSignal(toObservable(this.searchQuery).pipe(...));
Enter fullscreen mode Exit fullscreen mode

In the constructor:

constructor() {
  this.results = toSignal(toObservable(this.searchQuery).pipe(...));
}
Enter fullscreen mode Exit fullscreen mode

In ngOnInit — this will throw a runtime error.

The injection context requirement exists because toObservable() needs to register a cleanup callback tied to the component's lifecycle. Angular handles this automatically — you just need to make sure you're calling it in the right place.


Quick Reference

toObservable(signal)
  → Observable<T>
  → emits every time the signal changes
  → async (next microtask) — not synchronous
  → must be called in injection context
  → auto-cleanup — nothing to unsubscribe

Standard pattern:
  toSignal(
    toObservable(signal).pipe(
      debounceTime(300),
      switchMap(...)
    ),
    { initialValue: [] }
  )
Enter fullscreen mode Exit fullscreen mode

Wrapping Up

toObservable() closes the loop between Angular's two reactive primitives. You get the simplicity of Signals for state and template binding, and the full expressiveness of RxJS for stream composition — without either one leaking into the other's domain.

The pattern toSignal(toObservable(signal).pipe(...)) is going to show up a lot in modern Angular codebases. Get comfortable with it now.


Resources

If this was useful, the YouTube channel covers Angular, Spring Boot, Kubernetes, and real production engineering patterns — no fluff, no padding. Worth a subscribe if that's your kind of content.


Part of the Angular Signals & RxJS series. Next up: choosing between Signals, Observables, and the bridge functions — a practical decision framework.

Top comments (0)