Learn the RxJS you actually need for Angular, the operators that matter, and where signals have replaced it.

Bishal Biswas
Self Learner
What is left for RxJS
Signals took over state. RxJS still owns events over time: things that happen repeatedly, need cancelling, or need coordinating.
Signals RxJS
a value that changes a sequence of events
synchronous reads asynchronous streams
what the UI displays websockets, typing, polling, retries
You need far less RxJS than Angular used to demand, but the parts you need are worth knowing properly.
An observable is a stream
import { Observable } from 'rxjs';
const numbers$ = new Observable<number>(subscriber => {
subscriber.next(1);
subscriber.next(2);
subscriber.complete();
});
numbers$.subscribe({
next: value => console.log(value),
error: error => console.error(error),
complete: () => console.log('done'),
});
Three callbacks. next for each value, error once and then it stops, complete once and then it stops.
Errors terminate the stream. After an error, nothing more arrives. That single fact explains most RxJS surprises.
Cold and hot
const request$ = this.http.get<User[]>('/api/users');
request$.subscribe(); // one request
request$.subscribe(); // a second request
HTTP observables are cold: the work starts on subscribe, and each subscriber gets its own.
const clicks$ = fromEvent(button, 'click');
DOM events are hot: they happen whether anyone is listening, and subscribers share them.
Mixing this up is why the async pipe used twice makes two requests.
Creating them
of(1, 2, 3) // emits then completes
from([1, 2, 3]) // from an array, promise or iterable
fromEvent(element, 'click') // DOM events
interval(1000) // 0, 1, 2... every second
timer(500) // one value after 500ms
timer(0, 1000) // immediately, then every second
EMPTY // completes with nothing
NEVER // never emits or completes
throwError(() => new Error('bad')) // errors immediately
The operators worth knowing
Transforming:
source$.pipe(
map(x => x * 2),
filter(x => x > 10),
tap(x => console.log(x)), // side effect, does not change the value
scan((total, x) => total + x, 0), // a running accumulation
);
Timing:
source$.pipe(
debounceTime(300), // wait for a pause
throttleTime(1000), // at most one per second
distinctUntilChanged(), // ignore repeats
delay(500),
timeout(5000),
);
debounceTime and distinctUntilChanged together are the search box pattern, and probably the most used pair in Angular.
Combining:
combineLatest([a$, b$]) // the latest of each, once all have emitted
merge(a$, b$) // values from either, interleaved
concat(a$, b$) // all of a, then all of b
forkJoin([a$, b$]) // the final value of each, once all complete
withLatestFrom(b$) // a's values, each paired with b's latest
startWith(initial) // emit this first
forkJoin is the RxJS equivalent of Promise.all and only works with observables that complete. Using it with something that never completes hangs silently.
Error handling:
source$.pipe(
catchError(error => of(fallbackValue)),
retry({ count: 3, delay: 1000 }),
finalize(() => console.log('cleaned up')),
);
The flattening operators
This is the part worth understanding properly, because the choice changes behaviour.
All four take a value and return a new observable. They differ in what happens when a new value arrives while the previous inner observable is still running.
switchMap cancel the previous, use the new one
mergeMap run them all at once
concatMap queue them, one at a time in order
exhaustMap ignore the new one until the current finishes
switchMap for search and navigation:
this.searchTerm$.pipe(
debounceTime(300),
switchMap(term => this.http.get<Result[]>(`/api/search?q=${term}`)),
);
Typing a new term cancels the previous request. Without switchMap, a slow earlier response can arrive after a newer one and overwrite it.
concatMap for ordered writes:
this.saves$.pipe(
concatMap(change => this.http.patch('/api/doc', change)),
);
Each save completes before the next starts, so they apply in order.
exhaustMap for submit buttons:
this.submits$.pipe(
exhaustMap(() => this.http.post('/api/order', this.order())),
);
Double clicking submit does not create two orders, because the second click is ignored while the first is in flight.
mergeMap when order does not matter and you want maximum concurrency, such as uploading several files.
The default choice for HTTP triggered by user input is switchMap. Reach for the others deliberately.
Subscribing safely
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
export class Search {
constructor() {
this.service.updates$
.pipe(takeUntilDestroyed())
.subscribe(update => this.handle(update));
}
}
In a constructor it needs no argument. Elsewhere, pass a DestroyRef:
private readonly destroyRef = inject(DestroyRef);
load(): void {
this.service.get().pipe(takeUntilDestroyed(this.destroyRef)).subscribe();
}
The older pattern, which you will read constantly:
private readonly destroy$ = new Subject<void>();
ngOnInit(): void {
this.service.updates$.pipe(takeUntil(this.destroy$)).subscribe();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
takeUntilDestroyed replaced all of it.
Better still, avoid subscribing at all. Use toSignal, httpResource or the async pipe, and unsubscription is not your problem.
Converting to promises
import { firstValueFrom, lastValueFrom } from 'rxjs';
async save(): Promise<void> {
const user = await firstValueFrom(this.http.post<User>('/api/users', data));
this.router.navigate(['/users', user.id]);
}
firstValueFrom takes the first value then unsubscribes, which is right for HTTP. lastValueFrom waits for completion.
toPromise() is removed. Do not use it in new code.
When to use RxJS now
Yes:
- Debouncing typing.
- Cancelling superseded requests.
- Websockets and server sent events.
- Polling.
- Retries with back off.
- Coordinating several async sources.
No:
- Holding state a template reads. Use a signal.
- Simple HTTP GETs. Use
httpResource. - Deriving one value from another. Use
computed. - Component communication. Use a service with signals.
The historical use of a BehaviorSubject as a store is exactly what signals replaced.
Quick recap
- RxJS is for events over time, signals are for state.
- An error terminates a stream permanently.
- HTTP observables are cold, so each subscription is a new request.
debounceTimeplusdistinctUntilChangedis the search box pattern.switchMapcancels,concatMapqueues,exhaustMapignores,mergeMapparallelises.takeUntilDestroyedreplaced thedestroy$subject pattern.- Prefer not subscribing at all:
toSignal,httpResourceor theasyncpipe.
import { inject, DestroyRef } from '@angular/core';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { of, from, fromEvent, timer, interval, combineLatest, forkJoin, merge,
map, filter, tap, scan, debounceTime, distinctUntilChanged, startWith,
switchMap, concatMap, exhaustMap, mergeMap, catchError, retry,
finalize, firstValueFrom } from 'rxjs';
// An error TERMINATES the stream. Nothing arrives after it.
// That one fact explains most RxJS surprises.
// The search box pattern, and the most used pair in Angular
const results$ = searchTerm$.pipe(
debounceTime(300),
distinctUntilChanged(),
// switchMap CANCELS the previous request. Without it a slow earlier
// response can land after a newer one and overwrite it.
switchMap(term => http.get<Result[]>(`/api/search?q=${term}`)),
catchError(() => of([])),
);
// The four flattening operators differ ONLY in what happens when a new
// value arrives while the previous inner observable is still running:
// switchMap cancel the previous search, navigation
// concatMap queue them, in order ordered writes
// exhaustMap ignore the new one submit buttons, no double orders
// mergeMap run them all at once parallel uploads
const orderedSaves$ = changes$.pipe(concatMap(c => http.patch('/api/doc', c)));
const oneOrder$ = submits$.pipe(exhaustMap(() => http.post('/api/order', {})));
// Combining. forkJoin is Promise.all, and only works with streams that
// COMPLETE. Using it with one that never completes hangs silently.
const both$ = forkJoin([http.get('/api/a'), http.get('/api/b')]);
const latest$ = combineLatest([a$, b$]); // waits for each to emit once
// Retries with back off
const resilient$ = http.get('/api/data').pipe(
retry({ count: 3, delay: 1000 }),
finalize(() => console.log('done')));
// Subscribing safely. In a constructor takeUntilDestroyed needs no argument.
class Search {
private readonly destroyRef = inject(DestroyRef);
constructor() {
updates$.pipe(takeUntilDestroyed()).subscribe(u => this.handle(u));
}
load(): void {
http.get('/api/x').pipe(takeUntilDestroyed(this.destroyRef)).subscribe();
}
private handle(u: unknown) {}
}
// This replaced the old pattern you will read everywhere:
// private destroy$ = new Subject<void>();
// ngOnInit() { x$.pipe(takeUntil(this.destroy$)).subscribe(); }
// ngOnDestroy() { this.destroy$.next(); this.destroy$.complete(); }
// Better still, do not subscribe at all: toSignal, httpResource, async pipe.
const users = toSignal(http.get<User[]>('/api/users'), { initialValue: [] });
// Converting to a promise. toPromise() was REMOVED.
async function save() { return firstValueFrom(http.post('/api/users', {})); }
// Use RxJS for: debouncing, cancellation, websockets, polling, retries.
// Use signals for: state, derived values, component communication.
// A BehaviorSubject used as a store is exactly what signals replaced.
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready