Angular Signals vs RxJS Should You Replace RxJS in Real Apps

Summarize this blog post with:

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:

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);
  }
}

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

Signals solve that exact pain:

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

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

  setSelectedTab(tab: string): void {
    this.selectedTab.set(tab);
  }
}

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:

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

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

const total = computed(() => quantity() * price());

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:

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

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

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;
    });
  });
}

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

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) {}
}

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

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);
}

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.

Real-world use cases

1. Angular component state

Use Signals for:

  • Selected tabs
  • Modals
  • Filters
  • UI flags
import { signal } from '@angular/core';

export class LayoutComponent {
  readonly isSidebarOpen = signal(false);

  toggleSidebar(): void {
    this.isSidebarOpen.update((open) => !open);
  }
}

Replacing RxJS here usually improves readability.

2. Angular HTTP calls

Use RxJS for:

  • Request pipelines
  • Cancellation
  • Retry logic

Angular’s HttpClient returns Observables, making RxJS operators a natural fit for composing request pipelines. You can then convert the resulting Observable to a Signal for rendering, as shown below.

import { toSignal } from '@angular/core/rxjs-interop';
import { catchError, of } from 'rxjs';

readonly products = toSignal(
  this.productService.getProducts().pipe(
    catchError(() => of([]))
  ),
  { initialValue: [] }
);

Note: Signals require a current value. Observables may not emit synchronously, so toSignal() supports options like initialValue, undefined, and requireSync.

3. Angular forms

Use both:

  • Signals → UI state (validity, visibility)
  • RxJS → valueChanges, async validation, autosave

Code example:

import { computed } from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';
import { toSignal } from '@angular/core/rxjs-interop';
import {
  catchError,
  debounceTime,
  distinctUntilChanged,
  map,
  of,
  startWith,
  switchMap
} from 'rxjs';

export class UserFormComponent {
  readonly form = new FormGroup({
    email: new FormControl('', { nonNullable: true }),
    role: new FormControl('', { nonNullable: true })
  });

  private readonly emailAvailable$ = this.form.controls.email.valueChanges.pipe(
    map((email) => email.trim()),
    debounceTime(400),
    distinctUntilChanged(),
    switchMap((email) => {
      if (!email) {
        return of(null);
      }

      return this.userService.checkEmail(email).pipe(
        catchError(() => of(false))
      );
    })
  );

  readonly emailAvailable = toSignal(this.emailAvailable$, {
    initialValue: null
  });

  readonly formStatus = toSignal(
    this.form.statusChanges.pipe(startWith(this.form.status)),
    { initialValue: this.form.status }
  );

  readonly canSubmit = computed(() => {
    return this.formStatus() === 'VALID' && this.emailAvailable() === true;
  });

  constructor(private readonly userService: UserService) {}
}

The formStatus Signal is used to make the Observable-based form status easier to consume reactively in the template. It is a convenience pattern rather than a requirement for Angular forms.

Here, RxJS handles the async validation stream. Signal makes the result easy to consume in the template.

Comparison table

AreaAngular SignalsRxJS
Best use caseCurrent synchronous stateAsync streams and events over time
Component stateExcellentOften unnecessary for simple local state
Derived stateExcellent with computed()Useful when sources are Observables
HTTP callsGood for final UI stateBetter for request pipelines
Debounce and throttleNot the primary use caseExcellent
CancellationLimited directlyExcellent with switchMap
FormsGood for UI flags and derived stateStrong for valueChanges
Event handlingGood for simple state updatesStrong for event streams
Template usageDirect Signal readsasync pipe or conversion
Production architectureState layerAsync stream layer

Common mistakes to avoid

1. Replacing all RxJS with Signals

Not everything is state. Many things are streams.

Keep RxJS for:

  • Router events
  • WebSockets
  • Form streams
  • DOM events

2. Overusing toSignal()

Each toSignal() call subscribes to its source Observable. Avoid repeatedly converting the same Observable, and reuse the resulting Signal where possible.

3. Ignoring initial values

A Signal created with toSignal() may return undefined until its source Observable emits. Provide an initialValue, handle the possible undefined state, or use requireSync only when the source is guaranteed to emit synchronously.

4. Using Signals for async side effects

  • Avoid using effect() as a general replacement for RxJS pipelines.
  • Use RxJS when you need stream operators such as debouncing, cancellation, retries, or asynchronous composition.

Quick decision checklist

Use Signals when:

  • You need the current value
  • State is synchronous
  • It directly drives the UI

Use RxJS when:

  • Values arrive over time
  • You need operators like debounce or switchMap
  • You’re handling async workflows

Frequently Asked Questions

Can Signals replace state libraries like NgRx?

Signals can handle a significant amount of local and shared state, but large applications may still benefit from dedicated state-management patterns or libraries when they require centralized state, predictable update patterns, selectors, effects, debugging, or other advanced capabilities.

Should services expose Signals or Observables?

Expose Signals for state and Observables for streams.

Do Signals work with modern Angular setups?

Yes. Signals integrate with Angular’s modern change-detection model, including OnPush. For zoneless applications, verify the guidance for the Angular version you are using.

Conclusion

Angular Signals don’t replace RxJS, and they aren’t meant to. They solve a different problem.

  • Signals simplify state
  • RxJS handles time and async behavior

The best Angular apps don’t choose one over the other. They use both clearly and intentionally, in the right places.

Final thought

If you’re planning a migration, don’t rewrite everything.
Start small:

  • Replace simple BehaviorSubject usage with Signals
  • Keep RxJS pipelines where they matter

That’s how you modernize Angular without breaking what already works.

Be the first to get updates

Arunachalam Kandasamy RajaArunachalam Kandasamy Raja profile icon

Meet the Author

Arunachalam Kandasamy Raja

Arunachalam Kandasamy Raja is a software developer working with Microsoft technologies since 2022. He specializes in developing custom controls and components designed to improve application performance and usability. He is also actively exploring artificial intelligence and large language models to understand how AI-driven technologies can shape the future of modern software development.

Leave a comment