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
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
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)
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: [] }
);
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[] }
);
}
The Template
<input
[value]="searchQuery()"
(input)="searchQuery.set($event.target.value)"
/>
@for (user of results(); track user.id) {
<p>{{ user.name }}</p>
}
The Service
searchUsers(query: string): Observable<UserData[]> {
return of(MOCK_USERS.filter(u =>
u.name.toLowerCase().includes(query.toLowerCase())
)).pipe(delay(500));
}
Take a moment to look at what this component doesn't have:
- No
ngOnInit - No
subscribe() - No
unsubscribe()ortakeUntilDestroyed - No
asyncpipe 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 => ...)
);
And in the template:
<input (input)="search$.next($event.target.value)" />
@for (user of results$ | async; track user.id) { ... }
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(...));
In the constructor:
constructor() {
this.results = toSignal(toObservable(this.searchQuery).pipe(...));
}
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: [] }
)
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
- Full video walkthrough (with live coding demo): YouTube — Dominik Paszek
- Source code: [GitLab — link]
- Angular official docs — rxjs-interop
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)