Angular

RxJS vs Signals in Modern Angular Applications

How RxJS and Signals coexist in modern Angular apps, and practical patterns for using both together.

Tayyab Roy6 min read
Share:

If you’ve already read Signals vs RxJS: Which Should You Use and When, you know the high-level framing: signals are for synchronous state that templates read directly, RxJS is for asynchronous streams and complex event composition. What that article doesn’t go deep on is the part that actually matters once you’re building real features — the interop layer. In practice, you’re rarely choosing one exclusively. You’re converting between the two at specific boundaries, and getting those boundaries right is what makes a component clean instead of tangled.

This piece covers toSignal(), toObservable(), and the patterns that come up when you combine them — using a type-ahead search component as the running example, since it naturally needs both a stream (debounced HTTP calls) and template-friendly state (signals).

toSignal(): Observables Into Template-Ready State

toSignal() takes an observable and gives you back a signal that always holds its latest emitted value. This is most useful at the exact point where an RxJS-based service meets a component template — HTTP calls being the obvious case.

import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';

@Component({
  selector: 'app-user-list',
  template: `
    @if (users(); as list) {
      @for (user of list; track user.id) {
        <div>{{ user.name }}</div>
      }
    } @else {
      <app-spinner />
    }
  `,
})
export class UserListComponent {
  private http = inject(HttpClient);

  users = toSignal(this.http.get<User[]>('/api/users'));
}

Before toSignal() existed, this meant either subscribing manually in ngOnInit and managing a plain class property plus unsubscription, or using the async pipe in the template (*ngIf="users$ | async as list"). The async pipe is still perfectly valid, but toSignal() is preferable when you want that value available as a signal elsewhere in the component — in a computed(), in an effect, or passed as an @Input to a child — not just rendered directly in one spot.

Two details that matter in practice:

// Without an initial value, the signal starts as `undefined`
users = toSignal(this.http.get<User[]>('/api/users'));
// users(): User[] | undefined

// Give it a starting value to avoid undefined checks everywhere
users = toSignal(this.http.get<User[]>('/api/users'), { initialValue: [] });
// users(): User[]

toSignal() also automatically unsubscribes when the component is destroyed (it uses DestroyRef internally), so you lose nothing compared to a manual subscription with a takeUntilDestroyed() operator — it’s strictly less code for the same safety.

toObservable(): Feeding a Signal Into an RxJS Pipeline

The reverse direction comes up whenever you have signal-based state — usually something driven by user input — that needs to go through RxJS operators like debounceTime, switchMap, or distinctUntilChanged. Signals don’t have built-in time-based operators, and they shouldn’t — that’s exactly what RxJS is for.

import { Component, signal, inject } from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
import { debounceTime, distinctUntilChanged, switchMap, filter } from 'rxjs/operators';

@Component({
  selector: 'app-type-ahead-search',
  template: `
    <input
      [value]="query()"
      (input)="query.set($any($event.target).value)"
      placeholder="Search users..."
    />

    @if (loading()) {
      <app-spinner />
    } @else if (results().length) {
      @for (result of results(); track result.id) {
        <div class="result">{{ result.name }}</div>
      }
    } @else if (query().length > 1) {
      <p>No results found.</p>
    }
  `,
})
export class TypeAheadSearchComponent {
  private http = inject(HttpClient);

  // Signal: the raw, synchronous UI state driven by keystrokes
  query = signal('');
  loading = signal(false);

  // Bridge: turn the signal into a stream so RxJS can debounce it
  private results$ = toObservable(this.query).pipe(
    filter((q) => q.length > 1),
    debounceTime(300),
    distinctUntilChanged(),
    switchMap((q) => {
      this.loading.set(true);
      return this.http.get<SearchResult[]>(`/api/search?q=${q}`);
    }),
  );

  // Bridge back: the debounced, deduped, switchMap'd result as a signal
  results = toSignal(this.results$, { initialValue: [] as SearchResult[] });
}

There’s a subtlety worth calling out: loading.set(true) happens as a side effect inside the switchMap callback, and I still need to set it back to false once results arrive. A cleaner version separates that concern explicitly:

import { of } from 'rxjs';
import { tap, catchError } from 'rxjs/operators';

private results$ = toObservable(this.query).pipe(
  filter((q) => q.length > 1),
  debounceTime(300),
  distinctUntilChanged(),
  tap(() => this.loading.set(true)),
  switchMap((q) =>
    this.http.get<SearchResult[]>(`/api/search?q=${q}`).pipe(
      tap(() => this.loading.set(false)),
      catchError(() => {
        this.loading.set(false);
        return of([] as SearchResult[]);
      }),
    ),
  ),
);

switchMap automatically cancels the previous in-flight request when a new query comes in, which is exactly the behavior you want for type-ahead — if the user types faster than the network responds, only the latest request’s result matters. This cancellation behavior is something signals alone don’t give you; it’s RxJS’s actual job here, and it’s why this isn’t a case for a plain effect() making an HTTP call directly off the signal.

Why Not Just Use an Effect?

It’s tempting to skip toObservable() entirely and just run the search inside an effect():

// Avoid this for anything beyond trivial cases
constructor() {
  effect(() => {
    const q = this.query();
    if (q.length > 1) {
      this.http.get<SearchResult[]>(`/api/search?q=${q}`).subscribe((r) => {
        this.results.set(r);
      });
    }
  });
}

This compiles and even mostly works, but you lose debounceTime, distinctUntilChanged, and — critically — switchMap’s cancellation. Every keystroke past the first character fires a new HTTP request, and because there’s no cancellation, responses can come back out of order: if the request for “ang” happens to resolve after the request for “angular”, you’ll briefly show stale results for “ang” even though the user has already typed further. toObservable() exists precisely so you don’t have to reinvent request cancellation and timing control by hand inside an effect.

Loading and Error State: Signals vs RxJS Patterns

Both approaches can model loading/error state; they just put the responsibility in different places.

Pure RxJS, using a discriminated union emitted through the stream itself:

type SearchState =
  | { status: 'loading' }
  | { status: 'success'; results: SearchResult[] }
  | { status: 'error'; message: string };

results$: Observable<SearchState> = this.query$.pipe(
  debounceTime(300),
  switchMap((q) =>
    this.http.get<SearchResult[]>(`/api/search?q=${q}`).pipe(
      map((results): SearchState => ({ status: 'success', results })),
      startWith<SearchState>({ status: 'loading' }),
      catchError((err) => of<SearchState>({ status: 'error', message: err.message })),
    ),
  ),
);

Signals, using separate, independently-set signals:

loading = signal(false);
error = signal<string | null>(null);
results = signal<SearchResult[]>([]);

The RxJS discriminated-union approach guarantees the three states can never be inconsistent with each other, because there’s only one value flowing through the template (state().status). The separate-signals approach is more code to keep in sync by hand, but it’s more readable at the call site and doesn’t require template code to narrow a union. For a component with two or three states, I reach for separate signals. For anything with more than three states, or where invalid combinations would actually cause bugs (e.g., loading: true and error: 'x' both set at once), the discriminated union earns its complexity.

When to Reach for Which

  • Use toSignal() at the boundary where an observable’s latest value needs to live in template-facing or computed state.
  • Use toObservable() when signal-driven input needs time-based or cancellation-aware operators — debouncing, throttling, switchMap/exhaustMap, retry logic.
  • Don’t wrap every signal in an observable “just in case.” If a signal’s value only ever needs to be read synchronously (a toggle, a selected tab, a count), converting it to an observable and back adds indirection with no benefit.
  • Don’t replace switchMap-based cancellation with manual flags in an effect. It’s a common refactor mistake that reintroduces race conditions RxJS was already solving for you.

This same signal/RxJS boundary question comes up again, at a larger scale, when you’re deciding how to structure cross-component application state — covered in NgRx vs Signals: Choosing State Management — and if you haven’t worked through signal fundamentals yet, Angular Signals Explained with Examples is the right starting point before tackling interop.

#Angular#RxJS#Signals

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.