Angular – RxJS, and How Much of It You Still Need

July 21, 20264 min readUpdated 8/21/2026

Signals took over state. That is most of what an application holds, so most Angular code no longer touches RxJS at all. What signals do not model is a sequence of events over time — and that is where observables still win outright.

The distinction is worth stating plainly. A signal always has a value you can read right now. An observable is a stream: it may emit nothing, or many things, or fail, and it needs subscribing to. State is a value. Search-as-you-type is a stream.

One worked example

The menu search box is the one place in the pizza app where RxJS is unambiguously the right tool. The pipeline starts and ends in signals and is an observable only in the middle:

searchTerm (signal)  ->  toObservable  ->  operators  ->  toSignal  ->  template
private readonly searchState = toSignal(
  toObservable(this.searchTerm).pipe(
    debounceTime(SEARCH_DEBOUNCE_MS),
    map((term) => term.trim()),
    distinctUntilChanged(),
    switchMap((term) => {
      if (term.length < MIN_SEARCH_LENGTH) return of(IDLE);

      return this.api
        .get<Product[]>(`/api/search/products?q=${encodeURIComponent(term)}`)
        .pipe(
          map((results): SearchState => ({ loading: false, results })),
          catchError(() => of<SearchState>({ loading: false, results: [] })),
          startWith<SearchState>({ loading: true, results: null }),
        );
    }),
  ),
  { initialValue: IDLE },
);

Every operator there is fixing a specific bug. That is the standard to hold RxJS to — if you cannot name the bug an operator prevents, it should not be in the pipeline.

What each one buys

debounceTime(300) — one request when the typing stops, not one per keystroke.

map(trim) then distinctUntilChanged() — "pep " and "pep" are the same search, and re-typing the same term after a backspace does not refetch.

switchMap — the important one. If the response for "pep" is still in flight when "pepp" is typed, switchMap unsubscribes from it: the request is cancelled and its result can never arrive late and overwrite the newer one.

That out-of-order bug is the single best argument for RxJS in an app otherwise built on signals. It is real, it is intermittent, and it is miserable to debug — a slow response for a short query landing after a fast response for a long one, so the list shows results for a term the user has already finished editing. The React app solves the same problem with an AbortController by hand.

startWith inside the switchMap — emits the loading state for this request, so a cancelled request's spinner is cancelled with it. Putting it outside would leave the spinner on after a cancellation.

catchError — returns an empty result rather than killing the stream. ⚠️ An error that escapes here would complete the observable, and the search box would go dead for the rest of the page's life. That is the classic RxJS foot-gun, and it is why catchError is inside the switchMap: it recovers the inner request without tearing down the outer stream.

The flattening operators

switchMap is one of four, and choosing wrongly is where most RxJS bugs live:

switchMap — cancel the previous. Search, autocomplete, anything where only the latest matters.

mergeMap — run them all concurrently, in whatever order they finish. Wrong for search; right for independent uploads.

concatMap — queue them, strictly in order. Right when each request depends on the last having landed.

exhaustMap — ignore new ones while one is running. The correct answer for a submit button, where a double-click should not create two orders.

The bridge

import { toObservable, toSignal } from '@angular/core/rxjs-interop';

toSignal subscribes for you and unsubscribes when the injection context is destroyed, so there is no cleanup to write. It needs an initialValue unless the observable emits synchronously — without one the signal's type includes undefined.

toObservable goes the other way, turning a signal into a stream so operators can be applied to it. Both live in @angular/core/rxjs-interop and exist precisely so you do not have to pick one model for the whole app.

Where else it survives

HttpClient returns observables, so every request is one — usually converted immediately with firstValueFrom or toSignal.

NgRx effects are built on them, and the admin store uses switchMap and catchError for exactly the reasons above.

That is close to the whole list. The operators actually worth learning first are the ones in this lesson — map, filter, debounceTime, distinctUntilChanged, switchMap, catchError, startWith — plus shareReplay when several subscribers must share one request. The library has hundreds. You will not need most of them.

Proving it works

Cancellation is invisible from the rendered output, so the spec asserts on it directly:

expect(first.cancelled).toBe(true);
expect(second.cancelled).toBe(false);

HttpTestingController exposes cancelled, which is the only direct evidence that switchMap unsubscribed rather than letting both requests run. Asserting on the final results cannot tell "cancelled the stale request" from "got lucky with the ordering".

What is next

Turning URLs into components: the router.