Angular Signals vs RxJS: Which Should You Use and When?
A practical comparison of Angular Signals and RxJS — what each is actually good at, and how to decide which to reach for in a real app.
Every Angular team I’ve worked with over the last year has had some version of the same argument: now that Signals are stable, do we still need RxJS? The honest answer is yes, but not for the things you used to reach for it by default. Signals and RxJS solve different problems, and the teams that get the most mileage out of modern Angular are the ones that stop treating this as an either/or decision.
What Signals Actually Are
A signal is a synchronous, glitch-free container for a value. “Glitch-free” matters more than it sounds — it means that when a signal changes, every consumer sees a consistent view of derived state, with no intermediate or stale values leaking through during a single update cycle. There’s no subscription to manage, no unsubscribe to forget, and no async scheduling involved.
import { signal, computed, effect } from '@angular/core';
const quantity = signal(1);
const price = signal(25);
const total = computed(() => quantity() * price());
effect(() => {
console.log(`Total: $${total()}`);
});
quantity.set(3); // logs "Total: $75"
Reading a signal is just calling it as a function. computed() derives a new value lazily and re-evaluates only when one of its dependencies actually changes. There is no stream, no operator pipeline, no async boundary — it’s pull-based, synchronous state.
What RxJS Actually Is
RxJS models sequences of values over time, especially asynchronous ones: HTTP responses, WebSocket messages, user input events, timers. Its real strength is composition — operators like debounceTime, switchMap, combineLatest, and retry let you describe complex async coordination declaratively instead of hand-rolling it with flags and callbacks.
import { fromEvent } from 'rxjs';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';
const searchInput = document.getElementById('search') as HTMLInputElement;
fromEvent(searchInput, 'input').pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap((event) =>
this.searchService.search((event.target as HTMLInputElement).value)
)
).subscribe((results) => {
this.results = results;
});
Try to write that switchMap-based search-as-you-type flow with signals alone and you’ll end up reinventing cancellation and debouncing by hand. That’s not a knock on signals — it’s just not the problem they were designed to solve.
When Signals Are the Better Fit
Reach for signals for state that lives inside a component or a small service and doesn’t involve async orchestration:
- Local UI state — toggles, selected tabs, form field values, counters
- Derived values computed from other state (
computed()instead of a getter re-run on every change detection pass) - Component inputs, now natively supported via
input() - Anything where you’d previously have used a plain class property plus manual
markForCheck()calls
import { Component, signal, computed, input } from '@angular/core';
@Component({
selector: 'app-cart-item',
standalone: true,
template: `
<div class="cart-item">
<span>{{ name() }}</span>
<button (click)="decrement()">-</button>
<span>{{ quantity() }}</span>
<button (click)="increment()">+</button>
<strong>{{ lineTotal() | currency }}</strong>
</div>
`,
})
export class CartItemComponent {
name = input.required<string>();
unitPrice = input.required<number>();
quantity = signal(1);
lineTotal = computed(() => this.quantity() * this.unitPrice());
increment() {
this.quantity.update((q) => q + 1);
}
decrement() {
this.quantity.update((q) => Math.max(1, q - 1));
}
}
This is dramatically less ceremony than the equivalent with a BehaviorSubject and an async pipe, and change detection only runs where it needs to. If you haven’t worked through the fundamentals yet, Angular Signals Explained With Real Examples walks through signal(), computed(), and effect() in more depth.
When RxJS Is Still the Right Tool
Keep RxJS for genuinely asynchronous, multi-source, or time-based coordination:
- HTTP calls through
HttpClient, which still returns Observables - Debouncing or throttling user input
- Combining multiple independent async sources (
combineLatest,forkJoin,merge) - Retry/backoff logic, cancellation (
switchMap,takeUntil) - WebSocket or other long-lived event streams
import { combineLatest } from 'rxjs';
import { map, startWith } from 'rxjs/operators';
combineLatest([
this.filters.category$,
this.filters.priceRange$,
this.filters.inStockOnly$.pipe(startWith(false)),
]).pipe(
map(([category, priceRange, inStockOnly]) =>
this.productService.filter({ category, priceRange, inStockOnly })
)
).subscribe((products) => {
this.filteredProducts = products;
});
Trying to force this kind of multi-source async coordination into signals and effects usually ends with a tangle of effect() calls that re-run each other and are hard to reason about — effects are meant for synchronizing with the outside world (DOM, logging, storage), not for orchestrating async business logic.
Where They Meet: Interop
@angular/core/rxjs-interop gives you toSignal() and toObservable() so you’re not forced to pick one world per component.
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
import { inject } from '@angular/core';
export class ProductListComponent {
private productService = inject(ProductService);
// Observable -> Signal: great for template consumption
products = toSignal(this.productService.getProducts$(), { initialValue: [] });
category = signal<string>('all');
// Signal -> Observable: feed signal changes into an RxJS pipeline
private category$ = toObservable(this.category);
}
A very common pattern is: fetch data with RxJS (HttpClient, retries, caching), convert the result to a signal with toSignal() at the boundary, and then do all template-facing derivation with computed(). That gives you RxJS’s async strengths without leaking subscriptions into your templates. For a deeper look specifically at this interop boundary and migration patterns, see RxJS vs Signals in Angular.
Common Mistakes
Replacing every Subject with a signal. If you’re emitting events, not representing state (e.g., a “notify when save completes” signal), you probably still want a plain Subject or an Observable — signals model state, not discrete events.
Using effect() to synchronize two signals that could just be a computed(). If you write effect(() => total.set(quantity() * price())) instead of computed(), you lose memoization and you’ve introduced a side-effecting write that’s easy to get into a loop with. If the new value is a pure function of other signals, it’s a computed().
Overusing RxJS for state that never had more than one subscriber. A BehaviorSubject wrapped in an async pipe for a simple boolean toggle is strictly more code and more mental overhead than signal(false).
Forgetting that signals are synchronous. signal.set() takes effect immediately; there’s no microtask delay like you might assume from Promise-based mental models. This occasionally trips people up when they expect a signal read right after a .set() in the same function to reflect some kind of batched or async update — it doesn’t need to, because it already reflects the new value synchronously.
Deciding in Practice
A decent heuristic: if the data is a plain value derived from other values in memory, use signals. If it’s coming from outside the application (network, timers, DOM events) or needs operators to reshape a sequence of events, use RxJS, then bridge it into signals at the point where it reaches the template. If you’re also evaluating NgRx for larger applications, NgRx vs Signals for State Management covers where a full state management library still earns its keep versus when component-level or service-level signals are enough. And regardless of which reactive primitive you pick, strict typing pays off fast in either style — see TypeScript Best Practices for patterns that make both signals and RxJS pipelines safer to refactor.
FAQ
Do Signals replace ChangeDetectorRef.markForCheck()?
In most cases, yes. Signal-based components track dependencies automatically and schedule updates without you manually marking anything dirty.
Can I use signals inside RxJS operators?
Yes — reading a signal inside a map() or tap() is fine since it’s just a function call. Just remember it won’t trigger the observable to re-emit; the signal and the stream are still independent unless you explicitly bridge them with toObservable().
Is RxJS going away in future Angular versions?
No. HttpClient and the router still use Observables, and RxJS remains the right tool for async stream composition. Signals are an addition to Angular’s reactivity model, not a wholesale replacement.
Related reading
Written by
Tayyab Roy
Senior Frontend / Full-Stack Engineer
Senior Frontend & Full-Stack Engineer specializing in Angular, React, Next.js and enterprise eCommerce (Shopify, SAP Commerce / Hybris, Spartacus). I write about practical, production-tested frontend engineering.
Related Articles
NgRx vs Signals: Choosing State Management
When a full NgRx store still earns its complexity in an Angular app, and when Signals-based state is simpler and sufficient.
RxJS vs Signals in Modern Angular Applications
How RxJS and Signals coexist in modern Angular apps, and practical patterns for using both together.
Angular Standalone Components: Why They Matter
Why standalone components became Angular's default architecture, and how to structure an app around them.