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:
-
BehaviorSubjectfor 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
SubjectandBehaviorSubjectwrappers - 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);
}
}
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);
}
}
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());
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)}`)
)
);
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;
});
});
}
There is no need for:
combineLatestBehaviorSubject- 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) {}
}
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:
- Store UI input in a
Signal - Convert it to an
Observable - Use RxJS for async processing
- 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);
}
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)