The Angular community was divided at the time of the announcement of Signals. There were some members who rejoiced in what they perceived to be the demise of RxJS; however, others were fearful of having to rewrite their entire codebase. Both of these sentiments were misguided.
The point is this: RxJS was never intended for use as a library for managing state. We have been forcing it into this role since it has been the only reactive primitive in Angular for several years. We have abused the use of BehaviorSubject to store different primitive types such as strings or booleans, resulting in an ugly mess of subscriptions, memory leaks, and the dreaded ExpressionChangedAfterItHasBeenCheckedError.
Signals solve the issue of managing state while RxJS remains king of handling asynchronous events. To build a modern Angular app (without Zones) you must stop thinking of them as competitors and think of them as a pipeline instead.
Here is your definitive guide as to when to use what.
Rule 1: Use Signals for Synchronous State (The “What”)
If the value represents the current state of your UI, it needs to be a Signal. State is synchronous. If the user clicks the toggle button, the menu is open. It’s not “pending open” or “resolving open.” It’s simply open. It’s synchronous. The overhead of wrapping a boolean in a BehaviorSubject and subscribing with the async pipe is enormous architectural overhead for a synchronous piece of data.
Use Signals for:
- UI state (is loading, is open, selected tab).
- Form input values.
- Derived, calculated state (shopping cart totals).
- Data that has already arrived from the server and is resting in memory.
//State belongs to Signals
@Component({...})
export class CartComponent {
items = signal<CartItem[]>([]);
discount = signal(0.10);
// Synchronous, glitch-free, automatically tracked
total = computed(() => {
const subtotal = this.items().reduce((sum, item) => sum + item.price, 0);
return subtotal - (subtotal * this.discount());
});
}
Rule 2: Use RxJS for Asynchronous Events (The “When”)
However, signals don’t have the concept of time, so they can’t debounce user keystrokes, cancel an HTTP request because a new one was sent, or retry a failed WebSocket connection with exponential backoff.
If the value is something that occurs over time, something that can be canceled, it needs to be an RxJS Observable.
Use RxJS for:
- HTTP Requests (HttpClient).
- WebSocket streams.
- Complex DOM events (fromEvent for drag-and-drop).
- Race conditions (search typeaheads).
- Time-based operations (debounceTime, throttleTime, delay).
//Time and Network belong to RxJS
@Component({...})
export class SearchComponent {
searchControl = new FormControl('');
// You cannot do this with Signals
results$ = this.searchControl.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(term => this.api.search(term)), // Cancels previous requests
catchError(() => of([]))
);
}
Rule 3: Do Not Use effect() for Data Fetching
This is the most common mistake developers make when moving over to Signals.
The temptation with using the `effect()` function is that, because it is executed automatically when the associated Signals change, it is easy to want to use it for making HTTP requests
//The Junior Anti-Pattern
effect(() => {
// If userId changes rapidly, this fires multiple HTTP requests
// and creates a massive race condition. You cannot cancel them!
this.api.getUser(this.userId()).subscribe(data => {
this.userData.set(data);
});
});
The ‘effect()’ function is not cancellation-aware. If the user clicks through five IDs in quick succession, you will end up making five HTTP requests. The last one will overwrite the results, and you will end up with corrupted data, which is the one that completes last, not necessarily the fifth one.
The Fix: Use RxJS for the request, and then connect it to a Signal.
Rule 4: The Interop API (toSignal and toObservable)
The intersection of RxJS and Signals is where modern Angular excels. This is where you’ll find the bridge in @angular/core/rxjs-interop.
The golden rule of modern architecture is this: Events flow through RxJS, resolve, and rest as Signals.
You have a reactive search input? You use RxJS for the timing and cancellation concerns, and then immediately convert it to a Signal for the template to read synchronously
import { Component, signal } from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { switchMap, debounceTime } from 'rxjs';
@Component({
template: `
<input (input)="searchTerm.set($event.target.value)" />
@if (results()) {
<app-list [data]="results()" />
}
`
})
export class SearchComponent {
// 1. The UI State (Signal)
searchTerm = signal('');
// 2. The Bridge to RxJS (toObservable)
private search$ = toObservable(this.searchTerm).pipe(
debounceTime(300),
switchMap(term => this.api.fetchData(term))
);
// 3. The Bridge back to State (toSignal)
results = toSignal(this$, { initialValue: [] });
}
See how clean the template is? There’s no need for an | async pipe. There’s no need for a *ngIf. The component is reading a synchronous signal, but the complex asynchronous network logic is safely contained within an RxJS pipeline in the class.
Summary
You can use RxJS and Signals together as they complement each other very well.
- Signal = state (noun that will give you the Truth at any given moment).
- RxJS will provide you with how to respond to something that is happening over a period of time (verb).
- To create a pipeline between your user intention (Signal) and the ability to manage all network/time events (toObservable for HTTP) and then back to user intention (toSignal for display).
You should eliminate all BehaviorSubjects in your implementation and continue using switchMaps.
Comments
Loading comments…