AngularJSRxJS Operators

RxJS Operators

RxJS operators are pure functions that transform Observable streams. You chain them inside .pipe() to build data processing pipelines — filter, transform, combine, and control timing without ever leaving the reactive paradigm.

Angular uses RxJS operators heavily in HttpClient pipelines, form valueChanges, router events, and state management.

How pipe() Works

TS
import { of } from 'rxjs';
import { map, filter, take } from 'rxjs/operators';

// Each operator wraps the previous Observable
of(1, 2, 3, 4, 5).pipe(
  filter(n => n % 2 === 0), // 2, 4
  map(n => n * 10),          // 20, 40
  take(1),                   // 20 (then complete)
).subscribe(console.log);
20
Transformation Operators

map

TS
import { of } from 'rxjs';
import { map } from 'rxjs/operators';

// Transform each emitted value
of({ id: 1, name: 'Alice', age: 30 }).pipe(
  map(user => user.name.toUpperCase())
).subscribe(console.log); // "ALICE"

// With HTTP
this.http.get<ApiResponse>('/api/user/1').pipe(
  map(response => response.data) // unwrap the data field
).subscribe(user => this.user = user);

pluck (deprecated) → use map

TS
// Modern equivalent using map for property extraction
users$.pipe(
  map(users => users.map(u => u.name))
).subscribe(names => console.log(names));

scan — accumulate values (like Array.reduce but continuous)

TS
import { fromEvent } from 'rxjs';
import { scan } from 'rxjs/operators';

// Count total clicks
fromEvent(document, 'click').pipe(
  scan((count) => count + 1, 0)
).subscribe(count => console.log('Total clicks:', count));
Filtering Operators

TS
import { of, interval, fromEvent } from 'rxjs';
import { filter, take, takeWhile, skip, distinctUntilChanged, debounceTime, throttleTime } from 'rxjs/operators';

// filter — only pass values matching predicate
of(1, 2, 3, 4, 5).pipe(
  filter(n => n > 3)
).subscribe(console.log); // 4, 5

// take — complete after N values
interval(100).pipe(take(3)).subscribe(console.log); // 0, 1, 2

// takeWhile — complete when condition is false
interval(100).pipe(
  takeWhile(n => n < 5)
).subscribe(console.log); // 0, 1, 2, 3, 4

// skip — ignore first N values
of(1, 2, 3, 4, 5).pipe(skip(2)).subscribe(console.log); // 3, 4, 5

// distinctUntilChanged — suppress consecutive duplicates
of(1, 1, 2, 2, 3).pipe(
  distinctUntilChanged()
).subscribe(console.log); // 1, 2, 3

// debounceTime — emit only after N ms of silence (great for search)
searchInput.valueChanges.pipe(
  debounceTime(300),
  distinctUntilChanged()
).subscribe(term => this.search(term));

// throttleTime — emit max once per N ms (rate limiting)
fromEvent(document, 'mousemove').pipe(
  throttleTime(100)
).subscribe(evt => console.log(evt));
Higher-Order Mapping (Flattening) Operators

These operators subscribe to inner Observables produced by each emission. Choosing the right one matters.

Operator

Concurrency

On new emission

Best For

switchMap

One at a time

Cancels the previous

Search, autocomplete, route changes

mergeMap

Unlimited

Subscribes immediately

Parallel independent requests

concatMap

One at a time

Queues until previous completes

Sequential, order-preserved

exhaustMap

One at a time

Ignores new while running

Login button, form submit

TS
import { switchMap, mergeMap, concatMap, exhaustMap } from 'rxjs/operators';

// switchMap — search: cancel stale request when user types again
searchControl.valueChanges.pipe(
  debounceTime(300),
  switchMap(term => this.http.get<Product[]>(`/api/search?q=${term}`))
).subscribe(results => this.results = results);

// mergeMap — load all user details in parallel
from(userIds).pipe(
  mergeMap(id => this.http.get<User>(`/api/users/${id}`))
).subscribe(user => this.users.push(user));

// concatMap — process items in strict order (e.g., upload queue)
from(filesToUpload).pipe(
  concatMap(file => this.uploadService.upload(file))
).subscribe(result => this.completedUploads.push(result));

// exhaustMap — prevent double-submit
fromEvent(submitButton, 'click').pipe(
  exhaustMap(() => this.http.post('/api/save', this.formData))
).subscribe(response => this.saved = true);
Combination Operators

TS
import { combineLatest, forkJoin, zip, merge, concat, withLatestFrom } from 'rxjs';

// forkJoin — wait for ALL to complete, then emit combined result (like Promise.all)
forkJoin({
  users: this.http.get<User[]>('/api/users'),
  roles: this.http.get<Role[]>('/api/roles'),
}).subscribe(({ users, roles }) => {
  this.users = users;
  this.roles = roles;
});

// combineLatest — emit array of latest values when ANY source emits
combineLatest([
  this.filterControl.valueChanges,
  this.sortControl.valueChanges,
  this.pageControl.valueChanges,
]).pipe(
  switchMap(([filter, sort, page]) =>
    this.http.get<Item[]>(`/api/items?filter=${filter}&sort=${sort}&page=${page}`)
  )
).subscribe(items => this.items = items);

// withLatestFrom — combine with latest from secondary stream
saveButton.clicks$.pipe(
  withLatestFrom(this.currentUser$),
  switchMap(([_, user]) => this.saveService.save({ ...formData, userId: user.id }))
).subscribe();

// merge — interleave multiple Observables
merge(
  fromEvent(document, 'click'),
  fromEvent(document, 'keydown')
).subscribe(event => console.log('User action:', event.type));
Error Handling Operators

TS
import { catchError, retry, retryWhen, throwError, EMPTY, of } from 'rxjs';
import { delay, take } from 'rxjs/operators';

// catchError — handle errors, return fallback or re-throw
this.http.get<User[]>('/api/users').pipe(
  catchError((err) => {
    if (err.status === 404) return of([]);        // fallback value
    if (err.status === 401) return EMPTY;          // cancel silently
    return throwError(() => err);                  // re-throw
  })
).subscribe(users => this.users = users);

// retry — retry N times before emitting error
this.http.get('/api/flaky').pipe(
  retry({ count: 3, delay: 1000 }) // 3 retries, 1 second apart
).subscribe();

// retryWhen — custom retry logic (e.g., exponential backoff)
this.http.get('/api/data').pipe(
  retryWhen(errors =>
    errors.pipe(
      scan((retryCount, err) => {
        if (retryCount >= 3) throw err;
        return retryCount + 1;
      }, 0),
      delay(1000)
    )
  )
).subscribe();
Utility Operators

TS
import { tap, delay, timeout, finalize, shareReplay } from 'rxjs/operators';

// tap — side effects without transformation (logging, analytics)
this.http.get<User>('/api/user').pipe(
  tap(user => console.log('Fetched user:', user.name)),
  tap(user => this.analytics.track('user_loaded', user.id))
).subscribe(user => this.user = user);

// delay — add artificial delay (useful for testing)
of('hello').pipe(delay(2000)).subscribe(console.log);

// timeout — error if Observable doesn't emit within N ms
this.http.get('/api/data').pipe(
  timeout(5000) // throw TimeoutError if no response in 5s
).subscribe();

// finalize — cleanup on complete OR error (like try/finally)
this.http.get('/api/data').pipe(
  finalize(() => this.isLoading = false) // always runs
).subscribe();

// shareReplay — multicast and cache last N values for late subscribers
const user$ = this.http.get<User>('/api/me').pipe(
  shareReplay(1) // all subscribers share one HTTP call
);
Note
shareReplay(1) is extremely useful for expensive HTTP calls used in multiple places — the request fires once and all subscribers get the cached result.
Practical Angular Example: Typeahead Search

TS
// typeahead.component.ts
import { Component, OnInit, inject } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { AsyncPipe } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { Observable, of } from 'rxjs';
import {
  debounceTime, distinctUntilChanged, switchMap,
  filter, catchError, tap
} from 'rxjs/operators';

@Component({
  selector: 'app-typeahead',
  standalone: true,
  imports: [ReactiveFormsModule, AsyncPipe],
  template: `
    <input [formControl]="searchControl" placeholder="Search products..." />

    @if (loading) { <span>Searching...</span> }

    @for (result of results$ | async; track result.id) {
      <div class="result">{{ result.name }}</div>
    }
  `,
})
export class TypeaheadComponent {
  private http = inject(HttpClient);
  searchControl = new FormControl('');
  loading = false;

  results$: Observable<Product[]> = this.searchControl.valueChanges.pipe(
    debounceTime(300),           // wait 300ms after user stops typing
    distinctUntilChanged(),      // don't fire for same value
    filter(term => (term?.length ?? 0) >= 2), // minimum 2 chars
    tap(() => this.loading = true),
    switchMap(term =>            // cancel previous request
      this.http.get<Product[]>(`/api/products/search?q=${term}`).pipe(
        catchError(() => of([]))  // empty results on error
      )
    ),
    tap(() => this.loading = false)
  );
}
Operator Quick Reference

Category

Operators

Transform

map, scan, reduce, buffer, window

Filter

filter, take, skip, first, last, debounceTime, throttleTime, distinctUntilChanged

Combine

combineLatest, forkJoin, merge, zip, withLatestFrom, startWith

Flatten

switchMap, mergeMap, concatMap, exhaustMap

Error

catchError, retry, throwError, EMPTY

Utility

tap, delay, timeout, finalize, shareReplay

Conditional

takeWhile, takeUntil, skipWhile, skipUntil

Best Practices
  • Use switchMap for user-driven requests (search, navigation) to auto-cancel stale requests

  • Use forkJoin when you need all parallel requests to complete before proceeding

  • Always add catchError — unhandled errors terminate the Observable

  • Use shareReplay(1) to share expensive HTTP calls across multiple subscribers

  • Use debounceTime + distinctUntilChanged on form inputs before making requests

  • Prefer tap for side effects — never modify external state inside map

  • Use finalize() instead of duplicate error/complete handling for cleanup

  • Avoid deeply nested switchMaps — extract inner Observables to named functions

Summary

RxJS operators are the tools that make Observables powerful. Mastering the four higher-order mapping operators (switchMap, mergeMap, concatMap, exhaustMap) and the combination operators (combineLatest, forkJoin) will handle the vast majority of real-world Angular async patterns. Add catchError and shareReplay to every production data pipeline.