Angular has always been built around asynchronous, push-based data flow — HttpClient returns Observables, the Router exposes navigation events as Observables, and forms expose value changes as Observables. Since Angular 16-17, Signals were added as a second, complementary reactive primitive, specifically to solve change-detection performance problems that RxJS-plus-zone.js could not solve cleanly. Understanding both, and when to reach for each, is essential to modern Angular.


The Core Asynchronous Engine: Streams, Observables, Observers

An Observable is a lazy, push-based data producer — nothing executes until something subscribes. An Observer is the consumer object ({ next, error, complete }) that reacts to emitted values. A stream is the conceptual sequence of values an Observable produces over time — unlike a Promise, which resolves exactly once, an Observable can emit zero, one, or an unbounded number of values, and can be cancelled mid-stream.

observable-fundamentals.tstypescript
import { Observable } from 'rxjs';

// An Observable is fundamentally a function that receives a Subscriber
// and defines how/when to push values into it.
const ticker$ = new Observable<number>((subscriber) => {
  let count = 0;
  const intervalId = setInterval(() => {
    subscriber.next(count++);
    if (count > 5) {
      subscriber.complete();
    }
  }, 1000);

  // The returned teardown function runs on unsubscribe OR complete/error —
  // this is what prevents memory leaks from abandoned intervals/subscriptions.
  return () => clearInterval(intervalId);
});

const subscription = ticker$.subscribe({
  next: (value) => console.log('tick:', value),
  error: (err: unknown) => console.error('ticker failed:', err),
  complete: () => console.log('ticker finished'),
});

// Cancels the interval early via the teardown function above.
setTimeout(() => subscription.unsubscribe(), 3500);

Critical RxJS Operators in Production Code

Operators transform, filter, or combine streams without mutating the source Observable — each operator returns a new Observable. The four below cover the overwhelming majority of real-world Angular data-fetching logic.

features/operations-board/services/task-search.service.tstypescript
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Subject, Observable, of } from 'rxjs';
import { debounceTime, distinctUntilChanged, filter, map, switchMap, catchError } from 'rxjs/operators';
import type { OperationTask } from '../models/operation-task.model';

@Injectable({ providedIn: 'root' })
export class TaskSearchService {
  private readonly http = inject(HttpClient);
  private readonly searchTerm$ = new Subject<string>();

  readonly results$: Observable<OperationTask[]> = this.searchTerm$.pipe(
    // filter: skip empty/whitespace-only queries entirely — no request fired
    filter((term) => term.trim().length > 0),

    // debounceTime: wait for a 300ms pause in typing before proceeding,
    // preventing a network request on every keystroke
    debounceTime(300),

    // distinctUntilChanged: skip re-querying if the debounced term is
    // identical to the last one that was actually searched
    distinctUntilChanged(),

    // switchMap: maps the search term to an HTTP Observable, and
    // CANCELS any in-flight previous request — critical for search-as-you-type,
    // where an earlier slow response must never overwrite a later one
    switchMap((term) =>
      this.http.get<OperationTask[]>('/api/operations/tasks/search', { params: { q: term } }).pipe(
        map((tasks) => tasks.filter((task) => task.status !== 'complete')),

        // catchError: contain the failure to THIS request only — returning
        // of([]) means the stream stays alive for the next search term
        // instead of terminating the whole results$ Observable on one bad request
        catchError((error: unknown) => {
          console.error('Task search failed:', error);
          return of([]);
        })
      )
    )
  );

  search(term: string): void {
    this.searchTerm$.next(term);
  }
}
OperatorCategoryBehavior
mapTransformationApplies a synchronous projection function to each emitted value, 1:1
filterFilteringDrops emissions that fail a predicate; downstream operators never see them
switchMapFlatteningMaps each value to an inner Observable, unsubscribing from the PREVIOUS inner Observable when a new outer value arrives
catchErrorError handlingIntercepts an error, and can either return a replacement Observable (containing the stream) or re-throw
task-search.component.html — the async pipe eliminates manual subscription managementhtml
<input type="search" (input)="onSearchInput($event)" placeholder="Search tasks..." />

<!-- async pipe subscribes on render and automatically unsubscribes on
     component destroy — no ngOnDestroy cleanup required for this stream. -->
<ul>
  <li *ngFor="let task of taskSearch.results$ | async">{{ task.title }}</li>
</ul>

Modern Angular State: Signals

A Signal is a synchronous, glitch-free reactive primitive: reading a signal's value inside a reactive context (a template, a computed(), or an effect()) automatically registers that context as a dependent, so it re-runs precisely when the signal changes — with no zone.js and no subscription/unsubscription lifecycle to manage.

features/operations-board/task-board.component.tstypescript
import { Component, signal, computed, effect } from '@angular/core';
import type { OperationTask, TaskStatus } from '../models/operation-task.model';

@Component({
  selector: 'app-task-board',
  standalone: true,
  template: `
    <p>Showing {{ visibleTasks().length }} of {{ tasks().length }} tasks</p>
    <select (change)="onFilterChange($event)">
      <option value="all">All</option>
      <option value="in-progress">In Progress</option>
      <option value="blocked">Blocked</option>
    </select>
  `,
})
export class TaskBoardComponent {
  // signal(): a writable, observable container for a single value.
  readonly tasks = signal<OperationTask[]>([]);
  readonly statusFilter = signal<TaskStatus | 'all'>('all');

  // computed(): derives a new signal from others, re-evaluating ONLY
  // when tasks() or statusFilter() actually change — memoized, synchronous,
  // and requires no manual dependency array (unlike React's useMemo).
  readonly visibleTasks = computed(() => {
    const filterValue = this.statusFilter();
    const allTasks = this.tasks();
    return filterValue === 'all' ? allTasks : allTasks.filter((t) => t.status === filterValue);
  });

  constructor() {
    // effect(): runs a side effect whenever any signal read inside it
    // changes. Used for logging/analytics/localStorage sync — NOT for
    // deriving state (that's what computed() is for).
    effect(() => {
      console.log(`Filter changed to "${this.statusFilter()}", showing ${this.visibleTasks().length} tasks`);
    });
  }

  onFilterChange(event: Event): void {
    const select = event.target as HTMLSelectElement;
    this.statusFilter.set(select.value as TaskStatus | 'all');
  }
}

Signals vs RxJS: Choosing the Right Primitive

ConcernSignalsRxJS Observables
Synchronous local/UI stateIdeal — signal(), computed() are simpler and need no subscription cleanupOverkill — BehaviorSubject works but adds unsubscribe bookkeeping
Async event streams (HTTP, WebSocket, router events)Not a native fit — signals have no built-in operators for async compositionIdeal — Observables model asynchronous, cancellable, composable streams natively
Complex operator chains (debounce, switchMap, retry)Not supported directlyIdeal — this is RxJS's core strength
Template binding with automatic cleanupRead directly in templates: {{ mySignal() }}, no pipe neededRequires the async pipe for automatic subscription/unsubscription
Change detection granularityFine-grained — enables skipping unaffected subtreesCoarser — typically triggers a full zone.js-driven check

In practice, enterprise Angular code uses both together: HttpClient returns an Observable, which is converted to a signal at the boundary via toSignal() (from @angular/core/rxjs-interop) once the async data lands, so the rest of the component can consume it as a simple, synchronous signal without further subscription management.

task-board.component.ts — bridging RxJS to Signals with toSignal()typescript
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { OperationsService } from '../services/operations.service';

@Component({
  selector: 'app-task-board',
  standalone: true,
  template: `<p>{{ tasks()?.length ?? 0 }} tasks loaded</p>`,
})
export class TaskBoardComponent {
  private readonly operationsService = inject(OperationsService);

  // toSignal() subscribes once, converts each emission into the signal's
  // current value, and unsubscribes automatically on component destroy —
  // Observable ergonomics for the fetch, signal ergonomics for consumption.
  readonly tasks = toSignal(this.operationsService.getTasks());
}

Module 5 Checkpoint

  1. Q1. In a type-ahead search, why is switchMap preferred over mergeMap for the HTTP request?

  2. Q2. What is the key difference between computed() and effect() in Angular Signals?