Table of Contents
- Why developers want to replace RxJS
- What are Angular signals?
- What is RxJS in Angular?
- Angular Signals vs RxJS: The core difference
- Recommended architecture: Use both together
- Real-world use cases
- Comparison table
- Common mistakes to avoid
- Quick decision checklist
- Frequently Asked Questions
- Conclusion
- Related Blogs
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:
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:
combineLatestBehaviorSubject- 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:
- 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
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
| Area | Angular Signals | RxJS |
| Best use case | Current synchronous state | Async streams and events over time |
| Component state | Excellent | Often unnecessary for simple local state |
| Derived state | Excellent with computed() | Useful when sources are Observables |
| HTTP calls | Good for final UI state | Better for request pipelines |
| Debounce and throttle | Not the primary use case | Excellent |
| Cancellation | Limited directly | Excellent with switchMap |
| Forms | Good for UI flags and derived state | Strong for valueChanges |
| Event handling | Good for simple state updates | Strong for event streams |
| Template usage | Direct Signal reads | async pipe or conversion |
| Production architecture | State layer | Async 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
BehaviorSubjectusage with Signals - Keep RxJS pipelines where they matter
That’s how you modernize Angular without breaking what already works.
