Angular

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.

Tayyab Roy6 min read
Share:

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.

#Angular#Signals#RxJS#State Management

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.