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 -> templateprivate 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.