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.
On this page · 6 min · Intermediate
On this page
- Learning objectives
- Explanation
- Syntax
- Code example
- Step by step
- Real-world use
- Common mistakes
- Best practices
- Remember this
- Practice exercise
- Mini quiz
- Related topics
- My notes
- Reading time
- 6 min
- Difficulty
- Intermediate
- Updated
- Oct 28, 2026
Related tutorials
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#
results$ = this.query.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(q => this.api.search(q).pipe(catchError(() => of([])))),
);
Code example: A live country search#
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[] },
);
}
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#
- valueChanges emits every keystroke; startWith gives an initial value.
- debounceTime and distinctUntilChanged reduce requests to meaningful changes.
- filter ignores one-letter searches.
- switchMap maps each search term to an HTTP request and unsubscribes from the previous one.
- catchError inside switchMap turns a failed request into an empty list without ending the stream.
- 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 keeps every request alive, so slow old responses can overwrite new ones. Use switchMap for “latest wins”.
Catching outside switchMap completes the whole stream after the first error; later typing does nothing.
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?
Show solution
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#
Which operator cancels the previous inner request when a new value arrives?
Want more? Practise all Angular MCQs · Angular interview questions
My notes#
Saved in this browser.