Skip to content
Angular best practices 4 min read

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:

OperatorBehavior on a new source emissionTypical use
switchMapCancels the previous inner ObservableSearch, navigation, latest-wins
mergeMapRuns all inner Observables concurrentlyIndependent parallel writes
concatMapQueues inners, runs them in orderOrdered writes, sequential saves
exhaustMapIgnores new emissions while one is activeLogin, 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, but takeUntilDestroyed() makes it obsolete. Don’t add new destroy$ 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 async pipe or toSignal() 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, or exhaustMap.
  • Pick the flattening operator deliberately based on the cancellation/concurrency semantics you need.
  • For unavoidable imperative subscriptions, use takeUntilDestroyed() instead of manual destroy$ 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 catchError inside the pipeline so a failed inner stream doesn’t kill the outer one.
Last updated June 14, 2026
Was this helpful?