Angular RxJS Operators
โฑ๏ธ ~6-minute bite ยท solve the sandbox to master
5-Year-Old Metaphor
โ The physical, real-world picture. No jargon.๐ฆ The flattening operators all do the same thing โ take an outer observable and map each value to an inner observable โ but they differ in how they handle overlap.
๐ switchMap
Last request wins
Search autocomplete โ old results thrown out when you keep typing. Stale responses are irrelevant.
๐ exhaustMap
First click wins
Submit button โ clicking 5 times only fires once. Duplicate is worse than stale.
๐ concatMap
Take a number
Serial uploads โ each waits its turn. Order and completeness both matter.
๐ mergeMap
Open all counters
Parallel requests โ all go at once, results arrive as they complete.
๐ combineLatest
All lanes merge
Multi-source dashboard โ only emit when all sources have data, react when any changes.
Interactive Sandbox
โ Move something, see it react instantly.Operator
Last request wins โ cancels previous inner observable
๐ง Last request wins: search autocomplete โ old results thrown out when you keep typing.
Emission simulator โ click emit to fire the outer observable
Click โEmit!โ to simulate outer observable emissions
When to use
When new outer values make previous inner observables stale. Search autocomplete: only the latest search result matters. Route params: only the current route data matters.
| 1 | import { Component, OnInit } from '@angular/core'; |
| 2 | import { FormControl, ReactiveFormsModule } from '@angular/forms'; |
| 3 | import { switchMap, debounceTime, distinctUntilChanged } from 'rxjs/operators'; |
| 4 | import { HttpClient } from '@angular/common/http'; |
| 5 | ย |
| 6 | @Component({ |
| 7 | standalone: true, |
| 8 | imports: [ReactiveFormsModule], |
| 9 | template: `<input [formControl]="searchCtrl" placeholder="Search..." />`, |
| 10 | }) |
| 11 | export class SearchComponent implements OnInit { |
| 12 | searchCtrl = new FormControl(''); |
| 13 | results: string[] = []; |
| 14 | ย |
| 15 | constructor(private http: HttpClient) {} |
| 16 | ย |
| 17 | ngOnInit() { |
| 18 | this.searchCtrl.valueChanges.pipe( |
| 19 | debounceTime(300), |
| 20 | distinctUntilChanged(), |
| 21 | // switchMap: when a new value arrives, CANCEL the previous HTTP call |
| 22 | // The old XHR is aborted โ no stale results overwriting newer ones |
| 23 | switchMap((query) => this.http.get<string[]>(`/api/search?q=${query}`)), |
| 24 | ).subscribe((results) => { |
| 25 | this.results = results; |
| 26 | }); |
| 27 | } |
| 28 | } |
| 29 | ย |
| 30 | // โโ Route params (Angular Router pattern) โโโโโโโโโโโโโโโโโโโโโ |
| 31 | import { ActivatedRoute } from '@angular/router'; |
| 32 | ย |
| 33 | @Component({ template: '' }) |
| 34 | export class ArticleComponent implements OnInit { |
| 35 | constructor(private route: ActivatedRoute, private http: HttpClient) {} |
| 36 | ย |
| 37 | ngOnInit() { |
| 38 | this.route.paramMap.pipe( |
| 39 | switchMap((params) => { |
| 40 | const id = params.get('id'); |
| 41 | // Navigating to a new article cancels the old article's HTTP request |
| 42 | return this.http.get(`/api/articles/${id}`); |
| 43 | }), |
| 44 | ).subscribe((article) => { /* render */ }); |
| 45 | } |
| 46 | } |
Challenge
Explore all 5 RxJS operators. Use the emit simulator to see how each handles rapid emissions.
Why Should I Care?
โ The exact interview question + the bug it kills.Interview questions
Q: When should you use switchMap vs exhaustMap?
switchMap for search (stale results are bad โ keep only the latest). exhaustMap for submit (duplicates are bad โ ignore clicks while processing). The mental model: switchMap = โlast request winsโ, exhaustMap = โfirst click wins, ignore the rest until doneโ.
Q: What's the memory leak risk with mergeMap?
mergeMap has no concurrency limit by default โ if the source emits 100 values, mergeMap creates 100 simultaneous inner subscriptions. Each subscription holds memory until it completes. Always use takeUntilDestroyed() to cap the subscription lifetime, and consider the concurrency parameter mergeMap(fn, N) to limit simultaneous inner subscriptions.
Q: Why is combineLatest useful with form controls?
Multi-field validation requires knowing the current state of ALL fields simultaneously. combineLatest emits whenever any field changes and provides the latest value from every field. This lets you write validation logic that considers all fields together โ checking that password and confirmPassword match, or that email is valid before enabling the submit button.
The Deep Dive
โ Spec refs, engine internals, the minutiae.takeUntilDestroyed โ the modern cleanup pattern
Angular 16+ ships takeUntilDestroyed() in @angular/core/rxjs-interop. It automatically unsubscribes when the component is destroyed โ no more Subject + takeUntil + ngOnDestroy ceremony.
| 1 | import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; |
| 2 | ย |
| 3 | @Component({ standalone: true, template: '' }) |
| 4 | export class MyComponent { |
| 5 | private destroyRef = inject(DestroyRef); |
| 6 | ย |
| 7 | ngOnInit() { |
| 8 | this.someService.data$.pipe( |
| 9 | switchMap((query) => this.http.get(`/api/search?q=${query}`)), |
| 10 | takeUntilDestroyed(this.destroyRef), // auto-unsubscribe on destroy |
| 11 | ).subscribe((results) => { /* ... */ }); |
| 12 | } |
| 13 | } |
| 14 | ย |
| 15 | // Even simpler โ inject DestroyRef implicitly in the injection context: |
| 16 | export class CleanComponent { |
| 17 | constructor() { |
| 18 | interval(1000).pipe( |
| 19 | takeUntilDestroyed(), // no argument โ uses current injection context |
| 20 | ).subscribe(console.log); |
| 21 | } |
| 22 | } |
Hot vs cold observables
Cold observables start producing when subscribed โ each subscriber gets its own execution (HTTP calls are cold: each subscribe = new XHR). Hot observables produce regardless of subscribers โ events, Subjects, WebSockets. Understanding this matters for sharing: two components subscribing to http.get() each fire a separate HTTP request. Use shareReplay(1) to multicast.
| 1 | // Without shareReplay โ 2 HTTP requests if 2 components subscribe |
| 2 | const user$ = this.http.get('/api/me'); |
| 3 | ย |
| 4 | // With shareReplay(1) โ 1 HTTP request, result shared + replayed |
| 5 | const user$ = this.http.get('/api/me').pipe( |
| 6 | shareReplay(1), // cache latest value, replay to new subscribers |
| 7 | ); |
catchError + EMPTY โ graceful error handling
| 1 | import { catchError, EMPTY, of } from 'rxjs'; |
| 2 | ย |
| 3 | // catchError swallows the error and returns a fallback observable |
| 4 | // EMPTY completes without emitting โ the stream continues |
| 5 | this.search$.pipe( |
| 6 | switchMap((query) => |
| 7 | this.http.get(`/api/search?q=${query}`).pipe( |
| 8 | catchError((err) => { |
| 9 | console.error('Search failed:', err); |
| 10 | return EMPTY; // swallow error, don't kill the outer stream |
| 11 | // OR: return of([]); // emit empty results |
| 12 | }), |
| 13 | ), |
| 14 | ), |
| 15 | ).subscribe((results) => { /* ... */ }); |
| 16 | ย |
| 17 | // Without inner catchError: one HTTP error kills the entire subscription |
| 18 | // and search stops working for the rest of the session |
Interview Questions
โ Real questions from real interviews โ with answers.switchMap = last wins (search autocomplete); exhaustMap = first wins (form submit).
mergeMap has no concurrency limit by default โ N rapid emissions = N simultaneous subscriptions.
combineLatest doesn't emit until ALL source observables have emitted at least once. Use startWith() to seed each source.
Cold: each subscriber gets its own execution (HTTP = new XHR per subscribe). Hot: shared execution regardless of subscribers.
Catch inside the inner observable so errors are contained; the outer subscription survives.
Replaces the Subject + takeUntil + ngOnDestroy pattern โ automatically unsubscribes when the injection context is destroyed.
Memory Game
โ Quick quiz โ lock the concept in long-term memory.What does shareReplay(1) do that share() does not?