Angular course · Module 7: RxJS
RxJS Operators - Ninja Techniques
In this lesson3
Operators are pure functions that transform streams - like different ninja techniques changing the way information is passed.
Transformation Operators
import { map, switchMap, mergeMap, concatMap, exhaustMap } from 'rxjs/operators';
// map - transforms each element
source$.pipe(
map(value => value * 2)
);
// switchMap - switches to a new stream, cancels the previous one
// Ideal for search (cancel previous query)
searchInput$.pipe(
switchMap(query => this.http.get(`/search?q=${query}`))
);
// mergeMap - parallel execution of all
// Good when order doesn't matter
ids$.pipe(
mergeMap(id => this.http.get(`/item/${id}`))
);
// concatMap - sequential, preserves order
// When order is important
actions$.pipe(
concatMap(action => this.saveAction(action))
);
// exhaustMap - ignores new ones until the current one completes
// Ideal for save buttons (preventing double clicks)
saveButton$.pipe(
exhaustMap(() => this.saveData())
);Filtering Operators
import { filter, take, takeUntil, takeWhile, distinctUntilChanged, debounceTime } from 'rxjs/operators';
// filter - passes only matching elements
numbers$.pipe(
filter(n => n > 5)
);
// take - takes the first n elements
numbers$.pipe(
take(3) // Only the first 3
);
// distinctUntilChanged - removes consecutive duplicates
values$.pipe(
distinctUntilChanged() // [1,1,2,2,3,1] -> [1,2,3,1]
);
// debounceTime - waits for a pause in emissions
searchInput$.pipe(
debounceTime(300) // Waits 300ms without new values
);Combination Operators
import { combineLatest, forkJoin, merge, concat, zip } from 'rxjs';
// combineLatest - emits when any emits (after first emissions)
combineLatest([user$, settings$]).subscribe(([user, settings]) => {
console.log(user, settings);
});
// forkJoin - waits for all to complete (like Promise.all)
forkJoin([
this.http.get('/api/samurai'),
this.http.get('/api/weapons')
]).subscribe(([samurai, weapons]) => {
// Both completed
});
// merge - combines streams in parallel
merge(clicks$, keyups$).subscribe(event => {
console.log('Event:', event);
});Code for this lesson: src/app/app.component.ts
import { Component, signal, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Observable, Subject, of, from, timer, combineLatest, forkJoin } from 'rxjs';
import { map, delay, takeUntil, mergeMap, switchMap, concatMap, exhaustMap, catchError } from 'rxjs/operators';
@Component({
selector: 'app-root',
standalone: true,
imports: [CommonModule],
template: `
<div class="operators-dojo">
<h1>RxJS Operators Dojo</h1>
<section class="transformation">
<h2>Transformation (map, mergeMap, switchMap)</h2>
<div class="demo-box">
<h3>Map - value transformation</h3>
<button (click)="demoMap()">Run Map</button>
<div class="result">{{ mapResult() }}</div>
</div>
<div class="demo-box">
<h3>MergeMap - all in parallel</h3>
<button (click)="demoMergeMap()">Run MergeMap</button>
<div class="result">
@for (item of mergeMapResults(); track $index) {
<span class="tag">{{ item }}</span>
}
</div>
</div>
<div class="demo-box">
<h3>SwitchMap - cancel previous</h3>
<button (click)="demoSwitchMap()">Click quickly!</button>
<div class="result">
Clicks: {{ switchClickCount() }}<br>
Result: {{ switchMapResult() }}
</div>
</div>
</section>
<section class="combination">
<h2>Combination (combineLatest, forkJoin)</h2>
<div class="demo-box">
<h3>CombineLatest - latest from each</h3>
<div class="sliders">
<label>
Strength: {{ strength() }}
<input type="range" min="1" max="100" [value]="strength()"
(input)="onStrengthChange($event)">
</label>
<label>
Agility: {{ agility() }}
<input type="range" min="1" max="100" [value]="agility()"
(input)="onAgilityChange($event)">
</label>
</div>
<div class="result">
Total power: {{ combinedPower() }}
</div>
</div>
<div class="demo-box">
<h3>ForkJoin - wait for all</h3>
<button (click)="demoForkJoin()" [disabled]="forkJoinLoading()">
{{ forkJoinLoading() ? 'Loading...' : 'Fetch all data' }}
</button>
<div class="result">
@if (forkJoinResult()) {
<div>Samurai: {{ forkJoinResult()?.samurai }}</div>
<div>Weapons: {{ forkJoinResult()?.weapons }}</div>
<div>Missions: {{ forkJoinResult()?.missions }}</div>
}
</div>
</div>
</section>
<section class="error-handling">
<h2>Error Handling (catchError)</h2>
<div class="demo-box">
<h3>CatchError - defense against errors</h3>
<button (click)="demoWithError()">Simulate API error</button>
<div class="result" [class.error]="hasError()">
{{ errorResult() }}
</div>
</div>
</section>
</div>
`,
styles: [`
.operators-dojo {
font-family: 'Segoe UI', sans-serif;
padding: 20px;
background: linear-gradient(135deg, #2d1b4e 0%, #1a1a2e 100%);
min-height: 100vh;
color: #eee;
}
h1 {
text-align: center;
color: #a855f7;
}
h2 {
background: #a855f7;
color: white;
padding: 10px;
border-radius: 5px;
}
section {
margin: 20px 0;
}
.demo-box {
background: rgba(255,255,255,0.1);
padding: 15px;
border-radius: 10px;
margin: 10px 0;
}
.demo-box h3 {
margin-top: 0;
color: #a855f7;
}
button {
padding: 10px 20px;
background: #a855f7;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
margin: 5px 0;
}
button:hover:not(:disabled) {
background: #c084fc;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.result {
background: rgba(0,0,0,0.3);
padding: 10px;
border-radius: 5px;
margin-top: 10px;
min-height: 30px;
}
.result.error {
border: 2px solid #ef4444;
color: #fca5a5;
}
.tag {
display: inline-block;
background: #a855f7;
padding: 3px 8px;
border-radius: 3px;
margin: 2px;
font-size: 0.9em;
}
.sliders {
display: flex;
flex-direction: column;
gap: 10px;
}
.sliders label {
display: flex;
align-items: center;
gap: 10px;
}
.sliders input[type="range"] {
flex: 1;
}
`]
})
export class AppComponent implements OnDestroy {
private destroy$ = new Subject<void>();
private switchClick$ = new Subject<void>();
// Map demo
mapResult = signal('');
// MergeMap demo
mergeMapResults = signal<string[]>([]);
// SwitchMap demo
switchClickCount = signal(0);
switchMapResult = signal('');
// CombineLatest demo
strength = signal(50);
agility = signal(50);
combinedPower = signal(100);
// ForkJoin demo
forkJoinLoading = signal(false);
forkJoinResult = signal<any>(null);
// Error handling demo
hasError = signal(false);
errorResult = signal('Click the button to test');
constructor() {
// SwitchMap - cancels previous request
this.switchClick$.pipe(
takeUntil(this.destroy$),
switchMap(() => {
const clickNum = this.switchClickCount();
return timer(2000).pipe(
map(() => `Completed for click #${clickNum}`)
);
})
).subscribe(result => this.switchMapResult.set(result));
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
demoMap(): void {
const samurai = ['Musashi', 'Kojiro', 'Jubei'];
from(samurai).pipe(
map(name => `Samurai: ${name.toUpperCase()}`)
).subscribe({
next: val => this.mapResult.set(val),
complete: () => this.mapResult.update(v => v + '')
});
}
demoMergeMap(): void {
this.mergeMapResults.set([]);
const missions = ['Recon', 'Defense', 'Attack'];
from(missions).pipe(
mergeMap(mission =>
of(`${mission} completed`).pipe(
delay(Math.random() * 2000)
)
)
).subscribe(result => {
this.mergeMapResults.update(arr => [...arr, result]);
});
}
demoSwitchMap(): void {
this.switchClickCount.update(c => c + 1);
this.switchMapResult.set('Processing...');
this.switchClick$.next();
}
onStrengthChange(event: Event): void {
const value = +(event.target as HTMLInputElement).value;
this.strength.set(value);
this.updateCombinedPower();
}
onAgilityChange(event: Event): void {
const value = +(event.target as HTMLInputElement).value;
this.agility.set(value);
this.updateCombinedPower();
}
private updateCombinedPower(): void {
this.combinedPower.set(this.strength() + this.agility());
}
demoForkJoin(): void {
this.forkJoinLoading.set(true);
this.forkJoinResult.set(null);
forkJoin({
samurai: of(5).pipe(delay(1000)),
weapons: of(12).pipe(delay(1500)),
missions: of(3).pipe(delay(800))
}).subscribe(result => {
this.forkJoinResult.set(result);
this.forkJoinLoading.set(false);
});
}
demoWithError(): void {
this.hasError.set(false);
this.errorResult.set('Sending request...');
const failingApi$ = new Observable(subscriber => {
setTimeout(() => {
subscriber.error(new Error('Server unavailable!'));
}, 1000);
});
failingApi$.pipe(
catchError(error => {
this.hasError.set(true);
return of(`Error caught: ${error.message} - using fallback data`);
})
).subscribe(result => this.errorResult.set(result as string));
}
}Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. What is the main difference between switchMap and mergeMap?
2. What does the map() operator do in RxJS?
These are 2 of 5 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Horizontal ordering
Arrange the import of transformation operators from the rxjs operators package:
- Vertical ordering
Arrange the lines of the search operator chain in the order in which we write them inside pipe():
- Code editor
Complete the search stream's pipe so that it lets through only phrases with at least 3 characters.
- Click in order
Arrange the operator call on a stream: source$.pipe(map(...))