Angular · Data

RxJS

RxJS is a library for working with streams of values over time. In Angular it powers HttpClient, forms and the router — and a handful of operators solve most everyday problems.

Intermediate 6 min Lesson 24 of 38Examples target Angular 20 with standalone components. Each lesson states the version it was verified against.
Before you start:Observables
0/38 in Angular
On this page · 6 min · Intermediate

Learning objectives#

  • Transform streams with map and filter
  • Build a debounced search with debounceTime, distinctUntilChanged and switchMap
  • Handle errors with catchError
  • Combine streams with combineLatest

Explanation#

Operators are functions you chain with .pipe(): source$.pipe(map(...), filter(...)). Each returns a new Observable; the source is unchanged.

map transforms each value; filter drops values; tap runs side effects (logging) without changing values.

For search boxes: debounceTime(300) waits for typing to pause, distinctUntilChanged() ignores repeats, and switchMap(term => api.search(term)) starts a new request and cancels the previous one.

catchError(err => of(fallback)) recovers from errors so the stream keeps working; put it inside switchMap so one failed request does not end the whole search stream.

combineLatest([a$, b$]) emits whenever any input changes, with the latest value of each — e.g. filter + sort + data.

In new code, signals handle most local state; RxJS remains ideal for events over time, HTTP and cancellation.

Syntax#

TypeScript Illustrative
results$ = this.query.valueChanges.pipe(
  debounceTime(300),
  distinctUntilChanged(),
  switchMap(q => this.api.search(q).pipe(catchError(() => of([])))),
);

Code example: A live country search#

TypeScriptcountry-search.ts Illustrative
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { catchError, debounceTime, distinctUntilChanged, filter, map, of, startWith, switchMap, tap } from 'rxjs';


type Country = { name: { common: string }; capital?: string[] };


@Component({
  selector: 'app-country-search',
  imports: [ReactiveFormsModule],
  template: `
    <input [formControl]="query" placeholder="Type a country (min 2 letters)" />
    <p>Requests sent: {{ requests }}</p>
    <ul>
      @for (c of results(); track c.name.common) {
        <li>{{ c.name.common }} — {{ c.capital?.[0] ?? 'no capital' }}</li>
      } @empty {
        <li>No results</li>
      }
    </ul>
  `,
})
export class CountrySearch {
  private http = inject(HttpClient);
  query = new FormControl('', { nonNullable: true });
  requests = 0;


  results = toSignal(
    this.query.valueChanges.pipe(
      startWith(''),
      map(text => text.trim()),
      debounceTime(300),                       // wait for a pause in typing
      distinctUntilChanged(),                  // skip if the text did not change
      filter(text => text.length === 0 || text.length >= 2),
      switchMap(text =>                        // cancel the previous request
        text
          ? this.http.get<Country[]>(`https://restcountries.com/v3.1/name/${encodeURIComponent(text)}`).pipe(
              tap(() => this.requests++),
              map(list => list.slice(0, 5)),
              catchError(() => of([] as Country[])),   // 404 for no match → empty list
            )
          : of([] as Country[]),
      ),
    ),
    { initialValue: [] as Country[] },
  );
}
Expected output
Typing “ind” quickly sends a single request after you pause, listing e.g. “India — New Delhi” and “Indonesia — Jakarta”. Typing “xyz” shows “No results” instead of an error. Fast typing never shows stale results, because switchMap cancels older requests; the request counter grows much more slowly than the number of keystrokes.

This example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.

Step-by-step explanation#

  1. valueChanges emits every keystroke; startWith gives an initial value.
  2. debounceTime and distinctUntilChanged reduce requests to meaningful changes.
  3. filter ignores one-letter searches.
  4. switchMap maps each search term to an HTTP request and unsubscribes from the previous one.
  5. catchError inside switchMap turns a failed request into an empty list without ending the stream.
  6. toSignal exposes the final stream to the template.

Real-world use case#

An airline’s airport picker uses this exact pipeline: debounce, minimum length, switchMap to the API, and catchError — so the API receives a fraction of the keystrokes and results never arrive out of order.

Common mistakes#

mergeMap for searches

mergeMap keeps every request alive, so slow old responses can overwrite new ones. Use switchMap for “latest wins”.

catchError at the end of the outer pipe

Catching outside switchMap completes the whole stream after the first error; later typing does nothing.

Nested subscribe calls

subscribe inside subscribe is hard to cancel and leaks. Use switchMap/concatMap instead.

Best practices#

  • debounceTime + distinctUntilChanged + switchMap for typeaheads
  • catchError inside the inner stream
  • Avoid nested subscribes
  • Use signals for simple state, RxJS for events over time

Remember this#

pipe(map, filter, …) transforms streams.

Search = debounceTime → distinctUntilChanged → switchMap.

catchError inside switchMap keeps the stream alive.

Practice exercise#

Your task: Given price$ and qty$ Observables, create total$ with combineLatest that emits price × qty whenever either changes.

Need a hint?
combineLatest([price$, qty$]).pipe(map(([p, q]) => p * q)).
Show solution
TypeScript Illustrative
import { BehaviorSubject, combineLatest, map } from 'rxjs';


const price$ = new BehaviorSubject(60);
const qty$ = new BehaviorSubject(2);


export const total$ = combineLatest([price$, qty$]).pipe(map(([price, qty]) => price * qty));


total$.subscribe(total => console.log('total', total));   // total 120
qty$.next(3);                                              // total 180

Mini quiz#

RxJS quizMultiple choice Question 1 of 3

Which operator cancels the previous inner request when a new value arrives?

Score so far: 0/0

Want more? Practise all Angular MCQs · Angular interview questions

My notes#

Saved in this browser.

Progress is saved in this browser. Sign in to sync.