Angular Signals and RxJS: Two Tools for Two Different Problems
When Angular introduced Signals, several clients with RxJS-heavy codebases asked us the same question: "does this mean we stop using Observables?" The short answer is no. The longer answer is that Signals and RxJS solve different problems, and treating one as a replacement for the other is the mistake behind most of the half-finished migrations we've seen this past year.
The problem each one actually solves
RxJS exists to model streams of events over time: clicks, HTTP responses, WebSocket messages, route changes. An Observable doesn't hold a value, it holds a sequence of values that can arrive at any point, and its real strength lives in the operators that combine, transform, and cancel those streams: switchMap, debounceTime, combineLatest, takeUntil. None of that goes away with Signals, because Signals was never built to solve it.
Signals exists to model state: a value that changes over time and that something needs to read synchronously, right now, without subscribing to anything. A Signal<number> isn't a stream, it's a reactive memory cell. When you read it inside a computed or a template, Angular knows exactly what depends on what without you having to declare that dependency through an operator.
The confusion comes from both being "reactive" in the loose sense of the word, but reactive to what is the question that actually matters. RxJS reacts to external asynchronous events. Signals react to internal synchronous state changes. A click counter, a form with derived validation, an isOpen flag for a modal: that's state, and forcing it to live in a BehaviorSubject piped through .pipe(map(...)) just to read the current value in a template is exactly the complexity Signals came to remove, not relocate.
// Before: local state modeled as a stream, with the full RxJS ceremony
export class FilterComponent {
private searchTerm$ = new BehaviorSubject<string>('');
filteredCount$ = this.searchTerm$.pipe(
map(term => this.items.filter(i => i.name.includes(term)).length)
);
onSearch(term: string) {
this.searchTerm$.next(term);
}
}
// After: it's state, not a stream, and the code reflects that
export class FilterComponent {
searchTerm = signal('');
filteredCount = computed(() =>
this.items.filter(i => i.name.includes(this.searchTerm())).length
);
onSearch(term: string) {
this.searchTerm.set(term);
}
}
The second block isn't "RxJS but shorter." It's a more honest representation of what the data actually is: derived synchronous state, not a sequence of asynchronous events someone might need to cancel or combine with another stream.
When to reach for each one
The rule we apply in code review is simple, and it settles most style debates before they start: if the value has a well-defined current state and reading it doesn't involve waiting on anything, it's a Signal candidate. If the value represents something that happens over time and needs combination, cancellation, or concurrency control, it's still an Observable.
Clear Signal cases: form state, UI flags (loading, expanded, selectedTab), values derived from other signals via computed, anything that today lives in a plain @Input().
Clear Observable cases: HTTP requests with switchMap to cancel a stale one when a new request comes in, WebSocket streams, form input with debounceTime before triggering a search, any composition of multiple async sources with combineLatest or merge.
The real gray zone is state that starts as a one-off HTTP request and ends up consumed as a plain value in the template. That's where interop comes in, and it's the part teams migrating too fast tend to break.
Interop: toSignal and toObservable
Angular doesn't force you to pick a side. toSignal converts an Observable into a read-only signal, and it's the correct way to consume an HTTP request in a template without | async or manual subscription management:
export class UserProfileComponent {
private userService = inject(UserService);
user = toSignal(this.userService.getUser(), { initialValue: null });
}
The template reads user() directly, no async pipe, and Angular handles unsubscription when the component is destroyed. This doesn't replace the reactive request underneath: getUser() is still an Observable with all its retry, timeout, or cancellation logic if you need any of that before converting it. toSignal is the point where that asynchronous stream lands as a synchronous value for template consumption, not a substitute for RxJS in the request itself.
toObservable goes the other way, and it's less common but necessary when a state signal needs to feed an existing RxJS pipeline, for example to combine it with debounceTime before firing a search:
export class SearchComponent {
searchTerm = signal('');
private results$ = toObservable(this.searchTerm).pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(term => this.searchService.search(term))
);
results = toSignal(this.results$, { initialValue: [] });
}
This pattern — input signal, RxJS pipeline for the async logic, output signal — is the one we reach for most in search forms. The signal captures user interaction, which is state; RxJS handles debouncing and canceling in-flight requests, which is temporal flow control; and the result comes back as a signal because the template only needs to read a value.
Anti-patterns when porting RxJS-heavy code
The most frequent mistake we've seen in migrations is converting every local-state BehaviorSubject into a Signal mechanically, but leaving the manual .subscribe() in the constructor to keep things in sync, instead of using effect() or, better yet, removing the manual sync entirely because computed already handles it declaratively. The result is code with two reactive systems coexisting without a reason to, where one signal triggers an effect that updates another signal that triggers another effect: a chain of effects that's exactly the nested-subscription spaghetti well-used RxJS avoids.
Another anti-pattern: calling toSignal on an Observable that never completes or has no clear initial value, without passing initialValue, and then fighting the resulting T | undefined type throughout the template. If the Observable takes a moment to emit its first value, the signal needs to know what to show in the meantime; skipping initialValue just pushes that problem onto the consumer instead of resolving it at the conversion point.
The third one is the subtlest: wrapping a computed around a call that has side effects, like an HTTP request or a console.log touching mutable state. computed in Angular runs lazily and can recompute more often than the code visually suggests; if that function fires a network call, you end up with duplicate requests at unpredictable moments. Asynchronous side effects are still RxJS or explicit effect() territory, never a computed.
The rule that works for us
We don't migrate RxJS code that already works just because Signals is newer. We migrate when the current code is modeling synchronous state with async-stream tooling, because that's where Signals cuts real code and removes a source of forgotten-subscription bugs. Where the problem is genuinely asynchronous and needs temporal composition, RxJS stays, and forcing it into Signals just moves the complexity to a different file without reducing it.