DEV Community

Cover image for Angular Signals vs RxJS: Should You Replace RxJS in Real Apps?
Lucy Muturi for Syncfusion, Inc.

Posted on Originally published at syncfusion.com on

Angular Signals vs RxJS: Should You Replace RxJS in Real Apps?

TL;DR: Choose the right balance between Angular Signals and RxJS to build scalable, maintainable apps. Learn when to use each for state management, async workflows, HTTP calls, forms, and real-world architecture decisions without overcomplicating your code.

If you’ve worked with Angular for a while, you’ve probably used RxJS everywhere, sometimes more than necessary.

A typical component ends up with:

  • BehaviorSubject for UI state
  • async pipes in templates
  • .subscribe() and cleanup logic

For simple things like toggling a tab or tracking a selected item, that starts to feel like overkill.

Angular Signals change that. They make local state management feel simple again.

But once your feature grows, adding HTTP calls, debouncing, retries, or form streams, the question becomes bigger: Should Signals replace RxJS completely?

Short answer: No. And trying to do that often makes things worse.

Why developers want to replace RxJS

RxJS in Angular has always been powerful, but it can also become noisy.

Common pain points include:

  • Too many Subject and BehaviorSubject wrappers
  • Overuse of observable state for simple UI flags
  • Nested streams that are hard to debug
  • Manual subscription concerns
  • Template clutter with multiple async pipes
  • State services that expose everything as $

A typical Angular component often starts like this:

JavaScript

import { BehaviorSubject } from 'rxjs';

export class TabsComponent {
  private readonly selectedTabSubject = new BehaviorSubject('overview');

  readonly selectedTab$ = this.selectedTabSubject.asObservable();

  setSelectedTab(tab: string): void {
    this.selectedTabSubject.next(tab);
  }
}
Enter fullscreen mode Exit fullscreen mode

For a simple Angular component state, this feels heavier than necessary.

Signals solve that exact pain:

JavaScript

import { signal } from '@angular/core';

export class TabsComponent {
  readonly selectedTab = signal('overview');

  setSelectedTab(tab: string): void {
    this.selectedTab.set(tab);
  }
}
Enter fullscreen mode Exit fullscreen mode

This comparison focuses on local UI state, not shared event streams or cases that require Observable composition.

The improvement is not only in fewer lines. The state is easier to read, easier to update, and easier to bind in templates.

But real Angular apps are not only local state. They include Angular HTTP calls, route changes, forms, WebSockets, debounced inputs, polling, cancellation, retries, and user event streams. That is where RxJS still matters.

Angular also provides @angular/core/rxjs-interop to integrate Signals with RxJS via utilities like toSignal() and toObservable(), making coexistence a practical architectural choice.

What are Angular signals?

Signals expose a current value whenever they are read. When created from Observables, an explicit initial value or undefined state may be required until the first emission.

Basic example:

JavaScript

import { computed, signal } from '@angular/core';

const quantity = signal(2);
const price = signal(499);

const total = computed(() => quantity() * price());
Enter fullscreen mode Exit fullscreen mode

Signals are especially useful when a state has a current value, and derived values can be calculated synchronously.

No subscriptions. No async pipes. No extra layers.

Signals are a great fit for:

  • Local UI state
  • Toggles and flags
  • Selected items
  • Derived values (computed state)
  • Component view models

If your question is: What is the current value right now?

Signals are usually the right choice.

What is RxJS in Angular?

RxJS in Angular is used to work with asynchronous and event-based data streams. RxJS is about streams over time.

It powers Angular features like:

  • HttpClient
  • Form valueChanges
  • Router params
  • Event streams

Code example:

JavaScript

this.searchControl.valueChanges.pipe(
  debounceTime(300),
  distinctUntilChanged(),
  switchMap(query =>
    this.http.get(`/api/search?q=${encodeURIComponent(query)}`)
  )
);
Enter fullscreen mode Exit fullscreen mode

Here you’re not just tracking state, you’re managing:

  • time
  • async behavior
  • cancellation
  • retries

RxJS shines when you need

  • Debouncing or throttling
  • HTTP request handling
  • WebSocket streams
  • Event coordination
  • Complex async workflows

If your question is: How do values change over time?

That’s RxJS.

Angular Signals vs RxJS: The core difference

Think of it this way:

  • Signals → current value
  • RxJS → values over time

Signals are well suited to representing current reactive state and derived values, while RxJS is well suited to composing asynchronous, event-based, and time-dependent streams.

This distinction drives everything about how you design your app.

Signal example: Clean UI State

JavaScript

import { computed, signal } from '@angular/core';

interface Product {
  id: number;
  name: string;
  category: string;
}

export class ProductListComponent {
  readonly products = signal<Product[]>([]);
  readonly searchTerm = signal('');
  readonly selectedCategory = signal<string | null>(null);

  readonly filteredProducts = computed(() => {
    const term = this.searchTerm().toLowerCase();
    const category = this.selectedCategory();

    return this.products().filter((product) => {
      const matchesTerm = product.name.toLowerCase().includes(term);
      const matchesCategory = !category || product.category === category;

      return matchesTerm && matchesCategory;
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

There is no need for:

  • combineLatest
  • BehaviorSubject
  • map
  • shareReplay
  • async pipe

This is purely a synchronous state. Signals handle it perfectly.

RxJS example: Async + Time-Based Logic

JavaScript

import { HttpClient } from '@angular/common/http';
import { FormControl } from '@angular/forms';
import {
  catchError,
  debounceTime,
  distinctUntilChanged,
  map,
  of,
  switchMap
} from 'rxjs';

interface Product {
  id: number;
  name: string;
}

export class SearchComponent {
  readonly searchControl = new FormControl('', { nonNullable: true });

  readonly results$ = this.searchControl.valueChanges.pipe(
    map((query) => query.trim()),
    debounceTime(300),
    distinctUntilChanged(),
    switchMap((query) => {
      if (!query) {
        return of([]);
      }

      return this.http
        .get<Product[]>(`/api/products?q=${encodeURIComponent(query)}`)
        .pipe(
          catchError(() => of([]))
        );
    })
  );

  constructor(private readonly http: HttpClient) {}
}
Enter fullscreen mode Exit fullscreen mode

Here you need:

  • debouncing
  • cancellation
  • error handling

Signals alone are not a replacement for RxJS stream operators such as debounceTime, switchMap, and retry, or for composing cancellation behavior.

Recommended architecture: Use both together

In real production apps, the best pattern looks like this:

  1. Store UI input in a Signal
  2. Convert it to an Observable
  3. Use RxJS for async processing
  4. Convert the result back into a Signal

For workflows that require RxJS operators such as debouncing, cancellation, or stream composition, a Signal → Observable → RxJS → Signal pattern can be useful.

Code example: RxJS for Fetching, Signals for Rendering

JavaScript

import { Component, computed, inject, signal } from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import {
  catchError,
  debounceTime,
  distinctUntilChanged,
  map,
  of,
  startWith,
  switchMap
} from 'rxjs';

interface Product {
  id: number;
  name: string;
}

interface SearchState {
  data: Product[];
  loading: boolean;
  error: string | null;
}

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

    @if (loading()) {
      <p>Loading products...</p>
    }

    @if (error()) {
      <p class="error">{{ error() }}</p>
    }

    @if (!loading() && products().length === 0) {
      <p>No products found.</p>
    }

    <ul>
      @for (product of products(); track product.id) {
        <li>{{ product.name }}</li>
      }
    </ul>
  `
})
export class ProductSearchComponent {
  private readonly productService = inject(ProductService);

  readonly query = signal('');

  private readonly searchState$ = toObservable(this.query).pipe(
    map((query) => query.trim()),
    debounceTime(300),
    distinctUntilChanged(),
    switchMap((query) => {
      if (!query) {
        return of<SearchState>({
          data: [],
          loading: false,
          error: null
        });
      }

      return this.productService.searchProducts(query).pipe(
        map((data) => ({
          data,
          loading: false,
          error: null
        })),
        startWith({
          data: [],
          loading: true,
          error: null
        }),
        catchError(() =>
          of({
            data: [],
            loading: false,
            error: 'Unable to load products. Please try again.'
          })
        )
      );
    })
  );

  readonly searchState = toSignal(this.searchState$, {
    initialValue: {
      data: [],
      loading: false,
      error: null
    }
  });

  readonly products = computed(() => this.searchState().data);
  readonly loading = computed(() => this.searchState().loading);
  readonly error = computed(() => this.searchState().error);
}

Enter fullscreen mode Exit fullscreen mode

Note: ProductService is a custom application service that wraps HTTP calls. It is referenced here only to keep the example focused on Signals and RxJS interoperability.

Why this works well:

  • Signal owns the input state
  • RxJS handles debounce, cancellation, errors, and HTTP calls
  • Signal exposes final render state to the template

This approach keeps Angular async data streams powerful without making the template observable-heavy.

Read the full blog post on the Syncfusion Website

Top comments (0)