Signals vs Observables — I Built the Same Feature 3 Ways. Here's What I Learned.
Every Angular tutorial explains what Signals are. Every RxJS guide explains what Observables are. Neither one tells you which to reach for when you sit down to write an actual component.
That question kept coming up in my own work. So I took a real feature — a search input with debounce and an HTTP call — and built it three ways. Same result on screen. Completely different code underneath.
Here's what I found.
The Feature
A search input. User types, list filters. Debounce 300ms before the HTTP call fires, so we're not hammering the API on every keystroke.
Simple enough that the code stays readable. Real enough that it shows up in production.
[ search input ] → debounce 300ms → HTTP call → [ user list ]
Way #1 — BehaviorSubject + async pipe
This is how Angular developers have been doing it for years. Nothing wrong with it.
@Component({
selector: 'app-user',
template: `
<input (input)="search$.next($event.target.value)" />
@for (user of results$ | async; track user.id) {
<p>{{ user.name }}</p>
}
`
})
export class UserComponent {
private readonly userService = inject(UserService);
protected search$ = new BehaviorSubject<string>('');
protected results$ = this.search$.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(query =>
query ? this.userService.searchUsers(query) : of([])
)
);
}
What's good: battle-tested, the entire team knows how to read it, full RxJS power available in the pipeline.
What's not: look at the template. The async pipe is there. In a component with three or four streams, you start stacking async pipes everywhere — or reaching for *ngIf as tricks to combine them. The template starts to know too much about what's happening in the class.
Way #2 — BehaviorSubject + toSignal()
One change. We wrap the final Observable in toSignal() before it hits the template.
export class UserComponent {
private readonly userService = inject(UserService);
protected search$ = new BehaviorSubject<string>('');
protected results = toSignal(
this.search$.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(query =>
query ? this.userService.searchUsers(query) : of([])
)
),
{ initialValue: [] as UserData[] }
);
}
Template:
@for (user of results(); track user.id) {
<p>{{ user.name }}</p>
}
No async pipe. No subscribe(). Cleanup is automatic.
The RxJS pipeline stays exactly where it belongs — inside the component class, invisible to the template. toSignal() is just a thin wrapper at the output end that converts the Observable's last value into something the template can read synchronously.
This is my default recommendation for most situations. You get the full power of RxJS where you need it, and a clean template surface that doesn't leak implementation details.
One thing to remember: toSignal() must be called in an injection context — a field initializer or the constructor. Not in ngOnInit.
Way #3 — Signal + toObservable + toSignal
This is the fully modern approach. Zero Observables in the template, zero BehaviorSubject.
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[] }
);
}
Template:
<input
[value]="searchQuery()"
(input)="searchQuery.set($event.target.value)"
/>
@for (user of results(); track user.id) {
<p>{{ user.name }}</p>
}
toObservable() bridges the Signal into the RxJS pipeline. toSignal() brings the result back out. The template only ever sees Signals.
One thing worth knowing: toObservable() uses effect() under the hood, which runs asynchronously — the Observable emits in the next microtask, not synchronously. For UI interactions and HTTP calls this is completely invisible. For synchronous unit tests, you'll need to flush microtasks explicitly with fakeAsync + flushMicrotasks().
The Decision Framework
After going through all three, here's how I think about it:
Working on legacy code?
Keep BehaviorSubject + async pipe. Don't refactor for sport. The risk isn't worth the cosmetic improvement.
New component, but you already have Observable sources coming in?
Wrap the result with toSignal(). Clean template, zero risk, works with any existing pipe chain.
New component, starting fresh?
signal + toObservable + toSignal. This is where Angular is heading. Zero subscriptions, zero manual cleanup, RxJS where it belongs.
Need to share state between components?
Signal in a service. That's a separate topic — but the short version is: a signal() defined at the service level, injected where needed, is the modern replacement for a shared BehaviorSubject.
What Actually Changed Across the Three
The RxJS pipeline — debounceTime, distinctUntilChanged, switchMap — is identical in all three. That part doesn't change. RxJS is still the right tool for time-based stream manipulation.
What changes is how you feed it input and how you expose the output. That's it.
Way #1: Observable in, Observable out, async pipe in template.
Way #2: Observable in, Signal out, no async pipe.
Way #3: Signal in, Signal out, no Observable anywhere in the template.
The further right you move, the more Angular Signals owns the surface area. The RxJS stays inside, doing exactly what it's good at.
Resources
- Full video with live coding (all three approaches built from scratch): YouTube — Dominik Paszek
- Source code: GitLab — link
- Angular docs — rxjs-interop
If this kind of content is useful, the channel covers Angular, Spring Boot, Kubernetes and real production engineering patterns — no padding, no filler. Worth a subscribe if that's your area.
This wraps up the Angular Signals & RxJS block. Next up on the channel: Java, Spring Boot, backend architecture. Angular is coming back — Micro Frontends, state management at scale, performance patterns. Stay tuned.
Top comments (0)