RxJS Best Practices
RxJS is the reactive backbone of Angular — HTTP calls, router events, forms, and signals interop all flow through Observables. Used well, it produces declarative, composable, and leak-free code; used carelessly, it leaks subscriptions, nests callbacks, and turns simple flows into tangled state. This page covers the patterns that keep reactive code safe and readable in modern Angular (17/18/19): the async pipe, declarative pipelines, avoiding nested subscriptions, and proper teardown when you genuinely must subscribe.
Prefer the async pipe
The single most impactful rule: let the template own the subscription. The async pipe subscribes when the view renders, marks the component for change detection on each emission, and — crucially — unsubscribes automatically when the component is destroyed. You never leak, and you never write ngOnDestroy for it.
import { Component, inject } from '@angular/core';
import { AsyncPipe } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
interface User { id: number; name: string; }
@Component({
selector: 'app-user-list',
standalone: true,
imports: [AsyncPipe],
template: `
@if (users$ | async; as users) {
<ul>
@for (user of users; track user.id) {
<li>{{ user.name }}</li>
}
</ul>
} @else {
<p>Loading…</p>
}
`,
})
export class UserListComponent {
private http = inject(HttpClient);
users$: Observable<User[]> = this.http.get<User[]>('/api/users');
}
Bind the stream once with | async; as users. Re-piping the same Observable in multiple places creates multiple subscriptions (and multiple HTTP calls). Capture it in a local template variable instead.
Tip: With zoneless change detection and signals,
toSignal()is an even cleaner alternative — it gives you a synchronous value with no pipe and the same automatic teardown. See the interop example below.
Build declarative pipelines
Reactive code reads best as a single pipeline that transforms inputs into outputs. Resist the urge to subscribe, store an intermediate result in a field, then subscribe again. Use operators to express the whole flow.
import { Component, inject } from '@angular/core';
import { AsyncPipe } from '@angular/common';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { debounceTime, distinctUntilChanged, switchMap, startWith } from 'rxjs';
@Component({
selector: 'app-search',
standalone: true,
imports: [AsyncPipe, ReactiveFormsModule],
template: `
<input [formControl]="query" placeholder="Search users" />
@for (user of results$ | async; track user.id) {
<p>{{ user.name }}</p>
}
`,
})
export class SearchComponent {
private http = inject(HttpClient);
query = new FormControl('', { nonNullable: true });
results$ = this.query.valueChanges.pipe(
startWith(''),
debounceTime(300),
distinctUntilChanged(),
switchMap((term) =>
this.http.get<{ id: number; name: string }[]>(`/api/users?q=${term}`)
)
);
}
There is no manual subscription here at all — the template drives it. switchMap cancels the previous in-flight request when a new keystroke arrives, which is exactly the behavior a type-ahead needs.
Avoid nested subscriptions
Subscribing inside another subscription is the most common RxJS anti-pattern. It breaks cancellation, hides errors, and leaks. Flatten with the right higher-order operator instead.
// Anti-pattern: nested subscribe
this.route.params.subscribe((params) => {
this.http.get(`/api/users/${params['id']}`).subscribe((user) => {
this.user = user; // leaks, no cancellation, hard to test
});
});
// Declarative: flatten with switchMap
user$ = this.route.params.pipe(
switchMap((params) => this.http.get<User>(`/api/users/${params['id']}`))
);
Choose the flattening operator by the concurrency you want:
| Operator | Behavior on a new source emission | Typical use |
|---|---|---|
switchMap | Cancels the previous inner Observable | Search, navigation, latest-wins |
mergeMap | Runs all inner Observables concurrently | Independent parallel writes |
concatMap | Queues inners, runs them in order | Ordered writes, sequential saves |
exhaustMap | Ignores new emissions while one is active | Login, submit (block double-clicks) |
Unsubscribe properly when you must subscribe
Sometimes you genuinely need an imperative subscription — triggering a side effect, integrating a non-template stream. In modern Angular the idiomatic tool is takeUntilDestroyed(), which ties the subscription’s lifetime to the component or service.
import { Component, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { interval } from 'rxjs';
@Component({ selector: 'app-ticker', standalone: true, template: '' })
export class TickerComponent {
constructor() {
interval(1000)
.pipe(takeUntilDestroyed())
.subscribe((tick) => console.log('tick', tick));
}
}
Output:
tick 0
tick 1
tick 2
When called outside an injection context (e.g. inside a method), pass a DestroyRef:
private destroyRef = inject(DestroyRef);
start() {
interval(1000)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => this.poll());
}
Warning: The old
private destroy$ = new Subject()+ngOnDestroy+takeUntil(this.destroy$)boilerplate still works, buttakeUntilDestroyed()makes it obsolete. Don’t add newdestroy$subjects in modern code.
Interop with signals
For state you read in templates, convert streams to signals with toSignal(). It subscribes, unsubscribes on destroy, and exposes a synchronous value — no async pipe and no | async; as ceremony.
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
@Component({
selector: 'app-profile',
standalone: true,
template: `<h1>{{ user()?.name ?? 'Loading…' }}</h1>`,
})
export class ProfileComponent {
private http = inject(HttpClient);
user = toSignal(this.http.get<{ name: string }>('/api/me'));
}
Best Practices
- Prefer the
asyncpipe ortoSignal()so subscription teardown is automatic and never forgotten. - Express logic as a single declarative pipeline rather than subscribing, storing fields, and re-subscribing.
- Never subscribe inside a subscribe — flatten with
switchMap,mergeMap,concatMap, orexhaustMap. - Pick the flattening operator deliberately based on the cancellation/concurrency semantics you need.
- For unavoidable imperative subscriptions, use
takeUntilDestroyed()instead of manualdestroy$subjects. - Bind a stream to the template once and reuse it via
as; re-piping the same Observable creates duplicate subscriptions. - Always handle errors with
catchErrorinside the pipeline so a failed inner stream doesn’t kill the outer one.