RxJS essentials for Angular
The handful of operators that solve real problems, the subject patterns that avoid memory leaks, and when the answer is a signal instead.
The operators that earn their place
| Operator | Does | Reach for it when |
|---|---|---|
map | Transforms each value | Reshaping a response before it reaches the template |
filter | Drops values | Ignoring empty input before a request |
debounceTime | Waits for a pause | Search-as-you-type |
distinctUntilChanged | Suppresses repeats | Preventing a request when the value did not change |
switchMap | Cancels the previous inner observable | Type-ahead where only the latest result matters |
concatMap | Queues inner observables in order | Saving a list of records sequentially |
mergeMap | Runs inner observables concurrently | Independent parallel work |
exhaustMap | Ignores new values while busy | A submit button that must not double-fire |
catchError | Handles failure in the stream | Recovering with a fallback value |
shareReplay | Multicasts and caches | One HTTP call shared by several subscribers |
takeUntilDestroyed | Completes on component destroy | Any subscription made in an injection context |
import { Component, inject, DestroyRef } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { debounceTime, distinctUntilChanged, filter, switchMap, catchError, of, shareReplay } from 'rxjs';
@Component({
selector: 'app-product-search',
imports: [ReactiveFormsModule],
template: `<input [formControl]="term" placeholder="Search products" />`
})
export class ProductSearchComponent {
private readonly catalog = inject(CatalogService);
readonly term = new FormControl('', { nonNullable: true });
readonly results = toSignal(
this.term.valueChanges.pipe(
debounceTime(250), // wait for a typing pause
distinctUntilChanged(), // ignore unchanged values
filter((value) => value.length === 0 || value.length >= 3),
switchMap((value) => // cancel the previous request
this.catalog.search(value).pipe(
catchError(() => of([] as Product[])) // recover inside the switch
)
),
shareReplay({ bufferSize: 1, refCount: true })
),
{ initialValue: [] as Product[] }
);
}⚠️
switchMap cancels the previous inner observable, which is exactly right for search and exactly wrong for a save. Use concatMap when order matters and every value must complete, and exhaustMap when a second trigger should be ignored while the first is running.Subjects and lifetime
import { Subject, BehaviorSubject } from 'rxjs';
import { Injectable, Component, inject, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Injectable({ providedIn: 'root' })
export class NotificationService {
// BehaviorSubject: replays the current value to new subscribers.
private readonly queue = new BehaviorSubject<Notification[]>([]);
readonly queue$ = this.queue.asObservable();
push(note: { message: string; kind: 'info' | 'error' }) {
const list = [...this.queue.value, { ...note, id: crypto.randomUUID() }];
this.queue.next(list);
}
dismiss(id: string) {
this.queue.next(this.queue.value.filter((note) => note.id !== id));
}
}
@Component({ selector: 'app-bell', template: `<span>{{ (queue$ | async)?.length ?? 0 }}</span>` })
export class BellComponent {
private readonly notifications = inject(NotificationService);
private readonly destroyRef = inject(DestroyRef);
readonly queue$ = this.notifications.queue$;
constructor() {
// takeUntilDestroyed without arguments works in an injection context.
this.notifications.queue$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((list) => console.log('queue size', list.length));
}
}| Subject | Behaviour | Use for |
|---|---|---|
Subject | No initial value, no replay | Events where only live subscribers matter |
BehaviorSubject | Holds a current value, replays it | State that new subscribers must read immediately |
ReplaySubject(n) | Replays the last n values | Caching the last few results |
Subject as a destroy notifier | Emits once | Legacy equivalent of takeUntilDestroyed |
- An unsubscribed infinite observable keeps running and holds its closures.
takeUntilDestroyedis the shortest reliable fix and needs nongOnDestroy. asyncpipe subscriptions are cleaned up automatically, which is why binding the observable rather than subscribing manually is the safer default.- A
BehaviorSubjectin a root service survives navigation, so its state is global. If it should reset per route, provide the service at the route level instead of in root. - Check whether a signal would do: a
BehaviorSubjectthat is only read by templates is usually a signal that has not been converted yet.
Choosing between RxJS and signals
// Signals: state and derivation. Simple, synchronous, always current.
readonly term = signal('');
readonly isLongEnough = computed(() => this.term().length >= 3);
// RxJS: timing and composition. Explicit about when things happen.
readonly term$ = toObservable(this.term).pipe(
debounceTime(250),
distinctUntilChanged(),
switchMap((value) => this.catalog.search(value))
);
// Back to a signal at the template boundary.
readonly results = toSignal(this.term$, { initialValue: [] as Product[] });| Question | Signal | RxJS |
|---|---|---|
| Is it a current value? | Yes | Yes, but unwrapping costs you |
| Does timing matter (debounce, delay, interval)? | No | Yes |
| Must the previous work be cancelled? | No | Yes — switchMap |
| Must several streams be combined? | Limited | Yes — combineLatest, forkJoin |
| Is it read by a template? | Yes | Wrap in toSignal or the async pipe |
| Does it need a replay of past values? | No | Yes — a Subject variant |
Both models coexist deliberately. Write state as signals, keep the temporal logic in observables, and convert at the boundary with toSignal. What you should avoid is a component that mixes both styles in the same property — that is where the confusing bugs live.
FAQ
Do I have to unsubscribe from HttpClient calls?
No.
HttpClient requests complete after a single emission and tear themselves down. You do have to unsubscribe from infinite sources: interval, a WebSocket stream, valueChanges on a form control that outlives the component.Where does takeUntilDestroyed come from?
It is exported from
@angular/core/rxjs-interop. Called with no arguments it must be inside an injection context — a constructor or field initialiser. Anywhere else, pass an explicit DestroyRef.Related
Signals, computed values and effects HTTP, interceptors and async resources
Last refreshed 2026-09-18.