Angular RxJS in 2026: When Signals Aren’t Enough and Observables Win [2026]

Link copied
Angular RxJS in 2026: When Signals Aren’t Enough and Observables Win [2026]

Angular RxJS in 2026: When Signals Aren’t Enough and Observables Win [2026]

Angular Tutorial Module 7: RxJS Lesson 7.2

Signals replaced most everyday RxJS use in Angular. But RxJS didn't go away — it's still the foundation under HttpClient, EventSource-style streams, complex async orchestration, and any time-based or rate-limited data flow. This lesson is the 2026 decision framework: when reaching for RxJS is the cleaner answer, which operators are worth knowing, and when signal-based code crosses into RxJS-natural territory.

This is lesson 7.2 of the Angular Tutorial, following the lesson on debouncing user input (7.1). Lesson 3.7 (signal vs observable decision tree) is the prerequisite. This lesson is the practical follow-up.

When to reach for RxJS #

Five scenarios where Observables beat signals:

Scenario Why RxJS wins
Multi-value-over-time streams Signals only hold the CURRENT value. Streams (mouse moves, WebSocket frames, file uploads) need every value.
Operators with no signal equivalent withLatestFrom, combineLatest, scan, bufferTime, groupBy, windowBy
Complex async orchestration Cancellable retries with backoff, race conditions managed with switchMap
Cross-cutting interceptors and guards Already RxJS-based — the path is straightforward
Memoryful state machines When the next value depends on a history of previous values (scan)

For everything else (current state, derived state, two-way bindings, one-shot async), signals are simpler.

The operators worth knowing in 2026 #

Real-world Angular code uses ~10 operators out of RxJS's ~80. The shortlist:

map(fn) — transform each value #

user$.pipe(
  map(u => u.displayName.toUpperCase()),
);

Works like Array.map over time.

filter(pred) — drop values that don't match #

events$.pipe(
  filter(e => e instanceof NavigationEnd),
);

Classic for router events (lesson 5.4).

tap(fn) — side effects without changing the value #

http$.pipe(
  tap(res => console.log('Response:', res)),
  tap(res => analytics.track('api_call', { duration: res.headers.get('X-Duration') })),
);

Use for logging, analytics, or debugging — never for state mutation.

switchMap(fn) — cancel previous, run latest #

searchInput$.pipe(
  debounceTime(300),
  switchMap(q => http.get(`/api/search?q=${q}`)),
);

The canonical search-as-you-type pattern. When a new search term arrives, the previous in-flight request is unsubscribed (cancelled). httpResource (lesson 6.3) wraps this exact pattern in a signal-shaped API.

mergeMap(fn) — run all in parallel, emit each #

fileUpload$.pipe(
  mergeMap(file => http.post('/upload', file)),
);

Uploads run in parallel; results emit as each completes. Use when ALL results matter (parallel HTTP, fan-out queries).

concatMap(fn) — serial, in order #

queue$.pipe(
  concatMap(job => processJob(job)),
);

Next job waits for previous to finish. Use for queue processing or ordered operations.

exhaustMap(fn) — ignore new while busy #

saveClick$.pipe(
  exhaustMap(() => http.post('/save', form.value)),
);

While saving, new clicks are ignored (no duplicate saves). Perfect for submit buttons.

combineLatest([a$, b$]) — combine multiple streams #

combineLatest([userId$, filter$]).pipe(
  switchMap(([id, f]) => http.get(`/api/users/${id}?filter=${f}`)),
);

Emits whenever ANY input emits, with the latest of each. Useful for derived-from-multiple-sources flows.

scan((acc, value) => next, seed) — running aggregation #

click$.pipe(
  scan((count, _) => count + 1, 0),
);

Like Array.reduce over time. Use for counters, accumulators, running averages.

takeUntilDestroyed() — auto-unsubscribe on component destroy #

http$.pipe(
  takeUntilDestroyed(this.destroyRef),
).subscribe(...);

The modern replacement for the takeUntil(destroy$) pattern. Lesson 7.4 covers cleanup in depth.

switchMap vs mergeMap vs concatMap vs exhaustMap #

The "which mapping operator?" question, by use case:

Operator Behavior When
switchMap New value cancels in-flight previous Search (only latest query matters)
mergeMap All run in parallel Bulk uploads, fan-out queries
concatMap Wait for previous to finish Job queue, ordered ops
exhaustMap Ignore new while busy Submit button (don't double-submit)

A decision tree:

  • Should new requests cancel old?switchMap
  • Should new requests wait?concatMap
  • Should new requests be dropped?exhaustMap
  • Should all run in parallel?mergeMap

Getting this wrong is the most-frequent RxJS bug. Pick deliberately.

Real-world: tracking a multi-step upload #

A realistic case — combining many operators:

import { from, fromEvent, of } from 'rxjs';
import { switchMap, mergeMap, scan, map, takeUntilDestroyed } from 'rxjs';

export class BatchUploadComponent {
  private http = inject(HttpClient);
  private destroyRef = inject(DestroyRef);
  progress = signal({ total: 0, done: 0 });

  upload(files: FileList) {
    from(files).pipe(                                   // emit each file
      mergeMap(file => this.uploadOne(file), 3),         // 3-at-a-time concurrency
      scan(acc => acc + 1, 0),                            // count completions
      map(done => ({ total: files.length, done })),       // shape the progress
      takeUntilDestroyed(this.destroyRef),                // clean up on destroy
    ).subscribe(p => this.progress.set(p));
  }

  private uploadOne(file: File) {
    const form = new FormData();
    form.append('file', file);
    return this.http.post('/api/upload', form);
  }
}

Four operators, one signal at the end. The component renders progress via signal. This is the right shape: RxJS for the orchestration, signal at the boundary for the UI.

When combineLatest is the right answer #

For a list filtered by multiple signals:

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

filter = signal('all');
sort = signal<'asc' | 'desc'>('asc');

filtered = toSignal(
  combineLatest([toObservable(this.filter), toObservable(this.sort)]).pipe(
    switchMap(([f, s]) => this.http.get<Item[]>(`/api/items?filter=${f}&sort=${s}`)),
  ),
  { initialValue: [] },
);

Bridges in (signals → observable), orchestrates with RxJS, bridges out (observable → signal). The component sees a single signal.

The pure-signal version using httpResource would also work — pick based on which reads cleaner in your codebase.

Cleanup is automatic with takeUntilDestroyed #

The old pattern:

private destroy$ = new Subject<void>();
ngOnDestroy() { this.destroy$.next(); this.destroy$.complete(); }
foo$.pipe(takeUntil(this.destroy$)).subscribe(...);

The modern version:

foo$.pipe(takeUntilDestroyed()).subscribe(...);

Reads the DestroyRef from the current injection context, auto-unsubscribes on destroy. No destroy$ subject, no ngOnDestroy. Lesson 7.4 covers this in depth.

What NOT to use RxJS for #

Three anti-patterns:

  1. State holdingnew BehaviorSubject(0) is worse than signal(0) for state. Signal updates fire CD correctly in zoneless; BehaviorSubject doesn't on its own.
  2. Two-way data flow[(ngModel)] to a Subject is awkward. Use model() (lesson 3.3).
  3. Single async operationhttp.get(...).subscribe(...) for a one-shot fetch wastes the framework's machinery. Use httpResource() or firstValueFrom().

Reach for RxJS when the data has a STREAM character (multiple values over time, complex orchestration). Reach for signals when it has a STATE character (current value, derived value).

Common gotchas #

Symptom Cause Fix
switchMap doesn't cancel Wrong operator — used mergeMap instead Switch to switchMap for cancellation semantics
Subscription leaks Missing takeUntilDestroyed() Always pipe through it for component-scoped subscriptions
Operator runs in wrong order Operators in pipe are sequential — your order is your behavior Read the pipe top-to-bottom; reorder if needed
combineLatest doesn't emit One stream hasn't emitted yet — combineLatest waits for ALL to emit first Use startWith(initialValue) on each, OR use signals + computed
BehaviorSubject not triggering CD in zoneless Plain Subject changes don't trigger CD Convert to signal OR use async pipe (which subscribes correctly)

What's next #

Lesson 7.3 covers RxJS-signal interop in depth — toSignal, toObservable, when each bridge is the right call. Lesson 7.4 closes Module 7 with modern cleanup patterns (takeUntilDestroyed, DestroyRef).

Module 8 then picks up CSR performance — bundle splitting, OnPush, NgOptimizedImage, and the Web Vitals story.

Try it yourself #

A save button using exhaustMap (don't double-submit):

import { Component, signal, inject } from '@angular/core';
import { HttpClient, provideHttpClient, withFetch } from '@angular/common/http';
import { Subject } from 'rxjs';
import { exhaustMap, tap } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { bootstrapApplication } from '@angular/platform-browser';

@Component({
  selector: 'app-save',
  template: `
    <button (click)="save$.next()" [disabled]="saving()">{{ saving() ? 'Saving...' : 'Save' }}</button>
  `,
})
class SaveComponent {
  private http = inject(HttpClient);
  protected save$ = new Subject<void>();
  saving = signal(false);

  constructor() {
    this.save$.pipe(
      tap(() => this.saving.set(true)),
      exhaustMap(() => this.http.post('/api/save', {})),
      tap(() => this.saving.set(false)),
      takeUntilDestroyed(),
    ).subscribe();
  }
}

bootstrapApplication(SaveComponent, { providers: [provideHttpClient(withFetch())] });

Mash the Save button repeatedly — only one request fires at a time. exhaustMap drops the clicks that come in while a save is in flight.

YouI’m new to Angular in 2026. Do I need to learn RxJS deeply, or can I skip it?
YouYou can skip the deep dive. Learn the ~10 operators in this lesson (map, filter, tap, switchMap, mergeMap, concatMap, exhaustMap, combineLatest, scan, takeUntilDestroyed) — that covers 95% of what Angular apps use. For the rare cases where you need a fancier operator (groupBy, bufferTime, etc.), look it up when you hit the problem. The full RxJS API is overwhelming; the Angular slice is manageable.

Lesson 7.3 picks up RxJS-signal interop.

Up next in Angular

More from this topic

View all Angular articles →

Enjoyed this article?

Get new Angular tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email stays private. Required fields are marked *

Leave a Comment

Your email stays private. Required fields are marked *