Week 15: RxJS Deep Dive

You've used pipe(), map, and catchError since Week 14 without a full explanation of the library underneath. This week fixes that — the handful of operators that turn "requests" into genuinely composable async flows.

Phase 4 of 7 Week 15 of 26 ~4–5 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Compose non-trivial async flows with RxJS confidently
  • Choose the right flattening operator instead of defaulting to switchMap everywhere
  • Avoid the subscription leaks that quietly slow an app down over time

1. Subjects, BehaviorSubject & ReplaySubject

A plain Observable is unicast — each subscriber triggers its own independent execution. A Subject is both an Observable and an Observer at once: it multicasts, meaning every subscriber shares the same underlying execution and sees the same emitted values.

subject-basics.ts
import { Subject, BehaviorSubject, ReplaySubject } from 'rxjs';

const plain = new Subject<number>();
plain.subscribe((v) => console.log('A:', v));
plain.next(1); // A: 1
plain.subscribe((v) => console.log('B:', v)); // B never gets 1 -- it subscribed too late
plain.next(2); // A: 2, B: 2

const withInitial = new BehaviorSubject<number>(0);
console.log(withInitial.value); // 0 -- always has a current value, readable synchronously
withInitial.subscribe((v) => console.log('C:', v)); // C: 0 -- gets the CURRENT value immediately

const replay = new ReplaySubject<number>(2); // remembers the last 2 values
replay.next(1);
replay.next(2);
replay.next(3);
replay.subscribe((v) => console.log('D:', v)); // D: 2, D: 3 -- the last two, not the first

BehaviorSubject is the one you'll reach for most — it's exactly what a signal-based state service (Week 17) is conceptually built on: something that always has a current value, and notifies subscribers when it changes.

2. Core Flattening Operators

These four operators all solve the same shape of problem — you have a stream of "outer" events, and each one needs to trigger a new "inner" Observable (usually an HTTP call). They differ entirely in how they handle overlap between an in-flight inner Observable and a new outer emission.

switchMap — cancel and switch
searchTerm$.pipe(
  switchMap((term) => this.http.get<Result[]>(`/api/search?q=${term}`))
);
// New search term arrives while a request is in flight?
// The old request is CANCELLED. Only the latest term's result ever reaches you.
mergeMap — run everything concurrently
fileIds$.pipe(
  mergeMap((id) => this.http.post(`/api/files/${id}/process`, {}))
);
// Every inner Observable runs to completion, all in parallel, in whatever order they finish.
// Right for independent work; wrong when order or "only the latest" matters.
concatMap — queue, run in order
saveQueue$.pipe(
  concatMap((edit) => this.http.patch('/api/document', edit))
);
// Each save waits for the PREVIOUS save to finish before starting.
// Right whenever order matters -- out-of-order writes could corrupt the document.
exhaustMap — ignore new emissions while busy
submitClicks$.pipe(
  exhaustMap(() => this.http.post('/api/orders', this.orderPayload()))
);
// A second click while the first request is still in flight is IGNORED entirely.
// Right for submit buttons -- prevents accidental double-submission by design,
// not just by disabling the button (belt and suspenders).
Choosing between them

Ask what should happen to an in-flight request when a new trigger arrives: cancel it and start fresh (switchMap), let both run (mergeMap), queue behind it (concatMap), or ignore the new trigger entirely (exhaustMap). Picking the wrong one is a real, common source of subtle bugs — a search box using mergeMap can show stale results if an earlier request happens to resolve after a later one.

3. Combining Streams

Three more operators, for a different problem: combining multiple independent streams into one, rather than chaining one stream into another.

combineLatest — filters + sort, always current
combineLatest([this.searchTerm$, this.sortOrder$]).pipe(
  switchMap(([term, sort]) => this.productsService.search(term, sort))
);
// Re-emits whenever EITHER source emits, using the latest value from both.
// Requires every source to have emitted at least once before combineLatest emits anything.
forkJoin — parallel requests, wait for all
forkJoin({
  user: this.http.get<User>('/api/me'),
  settings: this.http.get<Settings>('/api/settings'),
}).subscribe(({ user, settings }) => {
  // Runs once, only after BOTH requests complete -- like Promise.all
});
withLatestFrom — read a value without reacting to it
this.submitClicks$.pipe(
  withLatestFrom(this.formValue$),
  map(([, formValue]) => formValue)
).subscribe((formValue) => this.save(formValue));
// Only emits when submitClicks$ emits -- formValue$ changing alone does NOT trigger anything.
// It just supplies "whatever the form's latest value happens to be" at click time.

withLatestFrom is the one that trips people up first: it's easy to expect it to behave like combineLatest, but the secondary stream is purely a value source here — only the primary stream's emissions actually drive the output.

4. Error Handling — Placement Matters

You used catchError and retry in Week 14 already — the one gotcha worth knowing now: where in the pipe catchError sits changes what it actually protects.

catchError inside switchMap — the usual mistake
searchTerm$.pipe(
  switchMap((term) =>
    this.http.get(`/api/search?q=${term}`).pipe(
      catchError(() => of([])) // catches the INNER Observable's error
    )
  )
).subscribe((results) => this.results.set(results));

// One failed search -> that emission becomes []. The OUTER stream keeps living --
// the user can keep typing and future searches still work. This is usually what you want.
catchError outside switchMap — kills the whole stream
searchTerm$.pipe(
  switchMap((term) => this.http.get(`/api/search?q=${term}`)),
  catchError(() => of([])) // catches errors from the WHOLE outer pipeline
).subscribe((results) => this.results.set(results));

// One failed search -> catchError fires, but the OUTER Observable completes right after.
// The subscription is now dead -- typing more into the search box does nothing at all.

As a rule: catch errors inside the inner Observable whenever the outer stream represents an ongoing sequence of independent events (search terms, clicks) that should keep working after one failure.

5. Subscription Management & Memory Leaks

Every manual .subscribe() needs a matching unsubscribe, or the subscription — and anything it's holding onto — lives for the lifetime of the app, not the component. Three approaches, from least to most automatic:

manual — easy to forget
export class SearchBox implements OnDestroy {
  private sub = this.results$.subscribe((r) => this.render(r));

  ngOnDestroy() {
    this.sub.unsubscribe(); // miss this, and the subscription outlives the component
  }
}
takeUntilDestroyed() — automatic, still a subscription
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';

export class SearchBox {
  constructor() {
    this.results$.pipe(takeUntilDestroyed()).subscribe((r) => this.render(r));
  }
}

Best of all, avoid manual subscription entirely — the async pipe in a template subscribes on render and unsubscribes automatically on destroy, and toSignal() (which you'll use constantly starting Week 16) does the same internally:

async pipe — no subscription code at all
results$ = this.searchTerm$.pipe(switchMap((term) => this.search(term)));
search-box.html
@for (result of results$ | async; track result.id) {
  <li>{{ result.name }}</li>
}

6. Building a Typeahead Search

Putting it all together — a search-as-you-type feature that doesn't spam the server on every keystroke, doesn't show stale results, and cleans itself up automatically.

typeahead-search.ts
import { Component, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { toSignal } from '@angular/core/rxjs-interop';
import { Subject } from 'rxjs';
import { debounceTime, distinctUntilChanged, switchMap, catchError, of } from 'rxjs';

@Component({
  selector: 'app-typeahead-search',
  templateUrl: './typeahead-search.html',
})
export class TypeaheadSearch {
  private http = inject(HttpClient);
  private searchTerm$ = new Subject<string>();

  results = toSignal(
    this.searchTerm$.pipe(
      debounceTime(300),           // wait for a pause in typing
      distinctUntilChanged(),      // skip re-searching an unchanged term
      switchMap((term) =>
        term.length < 2
          ? of([])                 // don't search on 0-1 characters
          : this.search(term).pipe(catchError(() => of([])))
      )
    ),
    { initialValue: [] }
  );

  onInput(value: string) {
    this.searchTerm$.next(value);
  }

  private search(term: string) {
    return this.http.get<Result[]>(`/api/search?q=${term}`);
  }
}

Every technique from this lesson shows up here: a Subject as the event source, debounceTime + distinctUntilChanged to cut down requests, switchMap to cancel stale in-flight searches, catchError placed inside the inner pipe so one failed search doesn't kill the whole feature, and toSignal() so there's no manual subscription to clean up at all.

7. Hands-on Exercise

Hands-on

Build a live search feature with proper cancellation

Extend Section 6's typeahead into a fully working, visibly correct search UI.

Requirements:

  1. A search input wired to a Subject, piped through debounceTime(300), distinctUntilChanged(), and switchMap into a (real or mocked) search request.
  2. A visible "searching…" indicator that appears the moment a request starts and disappears when results (or an empty array) arrive — track this with a separate signal set inside the pipe, not just inferred from the results themselves.
  3. Artificially delay your mock search by a random 200–1500ms, and type quickly through several search terms. Confirm — by logging each request's term — that a slower, earlier request's result never overwrites a faster, later one's.
  4. Add a catchError positioned correctly so that if one simulated search fails, typing a new term still works afterward.
  5. Use toSignal() for the results — no manual .subscribe() anywhere in the component.
Hint

If stale results ever do show up, double-check you're using switchMap and not mergeMap — this is the single most common cause of a "flickers back to old results" bug in a search box.

8. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

A new subscriber subscribes to a BehaviorSubject that already has values flowing through it. What do they receive first?

The current value, immediately — that's the defining feature of BehaviorSubject over a plain Subject. A plain Subject gives a late subscriber nothing until the next .next() call; BehaviorSubject always has a "current" value readable synchronously via .value, and hands it to any new subscriber right away.

Q2

A search box uses mergeMap instead of switchMap. Under what circumstance would a user see the wrong results?

If an earlier search's request happens to resolve after a later search's request — plausible with variable network latency — mergeMap lets both complete and the later, stale response can overwrite the newer, correct one, since nothing cancels the earlier request. switchMap avoids this entirely by cancelling the earlier request the moment a new term arrives.

Q3

Why does withLatestFrom(this.formValue$) not trigger an emission every time formValue$ changes?

withLatestFrom's secondary stream is purely a value source, not a trigger — only the primary stream's own emissions cause the combined Observable to emit. formValue$ updating just changes what value is available to be read the next time the primary stream emits; it doesn't cause an emission on its own the way combineLatest would.

Q4

Why does placing catchError outside a switchMap (rather than inside the inner Observable) break a search box after the first failed request?

An operator like catchError placed after switchMap catches errors from the whole outer pipeline — and once it handles one, the outer Observable completes. Since the outer stream represents "every future search term the user types," completing it means the subscription is now finished; nothing the user types afterward does anything. Catching the error inside the inner Observable (per request) means only that one request's failure is absorbed, and the outer stream — still listening for new search terms — stays alive.