Angular: Zero to Senior · Lesson 7 of 11

HTTP Client & RxJS Essentials

Angular uses RxJS Observables for async operations — including HTTP calls. Understanding both HttpClient and the core RxJS operators unlocks clean, composable async code.


Setup

Register HttpClient in app.config.ts:

TYPESCRIPT
import { provideHttpClient, withInterceptors } from '@angular/common/http';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideHttpClient(),
  ],
};

HttpClient Basics

TYPESCRIPT
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { inject } from '@angular/core';

export interface Product {
  id: number;
  name: string;
  price: number;
}

@Injectable({ providedIn: 'root' })
export class ProductService {
  private http = inject(HttpClient);
  private baseUrl = '/api/products';

  getAll(page = 1, pageSize = 20): Observable<Product[]> {
    const params = new HttpParams()
      .set('page', page)
      .set('pageSize', pageSize);
    return this.http.get<Product[]>(this.baseUrl, { params });
  }

  getById(id: number): Observable<Product> {
    return this.http.get<Product>(`${this.baseUrl}/${id}`);
  }

  create(product: Omit<Product, 'id'>): Observable<Product> {
    return this.http.post<Product>(this.baseUrl, product);
  }

  update(id: number, changes: Partial<Product>): Observable<Product> {
    return this.http.patch<Product>(`${this.baseUrl}/${id}`, changes);
  }

  delete(id: number): Observable<void> {
    return this.http.delete<void>(`${this.baseUrl}/${id}`);
  }
}

Subscribing in Components

With async pipe (preferred)

TYPESCRIPT
@Component({
  standalone: true,
  imports: [AsyncPipe, CommonModule],
  template: `
    @if (products$ | async; as products) {
      @for (p of products; track p.id) {
        <p>{{ p.name }} — {{ p.price | currency }}</p>
      }
    } @else {
      <p>Loading...</p>
    }
  `,
})
export class ProductListComponent {
  products$ = inject(ProductService).getAll();
}

The async pipe subscribes and automatically unsubscribes when the component is destroyed.

With subscribe() (when you need side effects)

TYPESCRIPT
@Component({ /* ... */ })
export class ProductDetailComponent implements OnInit, OnDestroy {
  private sub!: Subscription;
  product!: Product;
  isLoading = true;
  error = '';

  ngOnInit(): void {
    const id = Number(inject(ActivatedRoute).snapshot.paramMap.get('id'));
    this.sub = inject(ProductService).getById(id).subscribe({
      next:     (p)   => { this.product = p; this.isLoading = false; },
      error:    (err) => { this.error = err.message; this.isLoading = false; },
      complete: ()    => { this.isLoading = false; },
    });
  }

  ngOnDestroy(): void {
    this.sub.unsubscribe();
  }
}

Error Handling

TYPESCRIPT
import { catchError, throwError } from 'rxjs';
import { HttpErrorResponse } from '@angular/common/http';

@Injectable({ providedIn: 'root' })
export class ProductService {
  private http = inject(HttpClient);

  getAll(): Observable<Product[]> {
    return this.http.get<Product[]>('/api/products').pipe(
      catchError(this.handleError),
    );
  }

  private handleError(error: HttpErrorResponse): Observable<never> {
    let message = 'An error occurred';

    if (error.status === 0) {
      message = 'Network error — check your connection';
    } else if (error.status === 401) {
      message = 'Unauthorized — please log in again';
    } else if (error.status === 404) {
      message = 'Resource not found';
    } else if (error.status >= 500) {
      message = 'Server error — please try again later';
    } else {
      message = error.error?.message ?? error.message;
    }

    return throwError(() => new Error(message));
  }
}

HTTP Interceptors

Interceptors run on every request and response — perfect for auth tokens, logging, and error handling globally.

Auth Token Interceptor

TYPESCRIPT
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthService } from './auth.service';

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const token = inject(AuthService).getToken();

  if (token) {
    const authReq = req.clone({
      headers: req.headers.set('Authorization', `Bearer ${token}`),
    });
    return next(authReq);
  }

  return next(req);
};

Register interceptors:

TYPESCRIPT
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { authInterceptor } from './interceptors/auth.interceptor';

export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(withInterceptors([authInterceptor])),
  ],
};

Retry Interceptor

TYPESCRIPT
import { HttpInterceptorFn } from '@angular/common/http';
import { retry, timer } from 'rxjs';

export const retryInterceptor: HttpInterceptorFn = (req, next) => {
  return next(req).pipe(
    retry({
      count: 3,
      delay: (error, retryCount) => timer(retryCount * 1000), // 1s, 2s, 3s
    }),
  );
};

Essential RxJS Operators

Transformation

TYPESCRIPT
import { map, switchMap, mergeMap, concatMap, exhaustMap } from 'rxjs/operators';

// map — transform each emitted value
users$.pipe(
  map(users => users.filter(u => u.active)),
)

// switchMap — cancel previous inner observable on new emission
// Perfect for search-as-you-type (cancels in-flight request)
searchTerm$.pipe(
  debounceTime(300),
  distinctUntilChanged(),
  switchMap(term => this.productService.search(term)),
)

// mergeMap — run all inner observables concurrently
// Good for parallel API calls
userIds$.pipe(
  mergeMap(id => this.userService.getById(id)),
)

// concatMap — wait for each inner observable to complete before starting next
// Good for sequential operations
orderIds$.pipe(
  concatMap(id => this.orderService.process(id)),
)

// exhaustMap — ignore new emissions while inner observable is running
// Perfect for submit buttons (ignores double-clicks)
submitClicks$.pipe(
  exhaustMap(() => this.orderService.submit(this.form.value)),
)

Filtering

TYPESCRIPT
import { filter, take, takeUntil, debounceTime, distinctUntilChanged } from 'rxjs/operators';

// filter — only pass values that satisfy a predicate
users$.pipe(filter(u => u.age >= 18))

// debounceTime — wait for a pause in emissions (search inputs)
input$.pipe(debounceTime(300))

// distinctUntilChanged — skip if value is same as last emission
input$.pipe(distinctUntilChanged())

// take — complete after N emissions
timer(0, 1000).pipe(take(5))  // emit 5 times, then complete

// takeUntil — complete when a notifier emits (great for unsubscribing)
this.http.get('/api/data').pipe(
  takeUntil(this.destroy$),
)

Combination

TYPESCRIPT
import { combineLatest, forkJoin, zip } from 'rxjs';

// forkJoin — wait for all to complete, emit last value of each
// Perfect for parallel API calls where you need all results
forkJoin({
  user:     this.userService.getById(id),
  orders:   this.orderService.getByUserId(id),
  products: this.productService.getFeatured(),
}).subscribe(({ user, orders, products }) => {
  // all three available together
});

// combineLatest — emit whenever any source emits, with latest from all
// Perfect for combining filters
combineLatest([search$, categoryFilter$, sortOrder$]).pipe(
  debounceTime(100),
  switchMap(([search, category, sort]) =>
    this.productService.getAll({ search, category, sort })
  ),
)

Error Handling

TYPESCRIPT
import { catchError, retry, retryWhen, throwError, EMPTY } from 'rxjs';

// catchError — handle error, return fallback or rethrow
products$.pipe(
  catchError(err => {
    console.error(err);
    return of([]);         // return empty array as fallback
    // OR: return throwError(() => err);  // rethrow
    // OR: return EMPTY;                  // complete silently
  }),
)

// retry — retry N times on error
http.get('/api/data').pipe(retry(3))

takeUntilDestroyed — Modern Unsubscription (Angular 16+)

No need to implement OnDestroy manually:

TYPESCRIPT
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { DestroyRef, inject } from '@angular/core';

@Component({ /* ... */ })
export class MyComponent {
  private destroyRef = inject(DestroyRef);

  ngOnInit(): void {
    this.someService.data$.pipe(
      takeUntilDestroyed(this.destroyRef),
    ).subscribe(data => this.data = data);
  }
}

Signals vs Observables

Angular 16+ introduced signals as a simpler reactive primitive:

TYPESCRIPT
// Observable approach
products$ = this.productService.getAll();
// Template: {{ products$ | async }}

// Signal approach (Angular 17+)
products = toSignal(this.productService.getAll(), { initialValue: [] });
// Template: {{ products() }}  — no async pipe needed

Use Observables for HTTP calls and event streams. Use signals for local component state and derived values.


Quick Reference

Setup:          provideHttpClient() in app.config.ts
GET:            http.get(url, { params })
POST:           http.post(url, body)
PATCH/PUT:      http.patch(url, body)
DELETE:         http.delete(url)
Interceptors:   HttpInterceptorFn → withInterceptors([fn])
Error:          .pipe(catchError(err => throwError(() => err)))
Retry:          .pipe(retry({ count: 3, delay: ... }))

Key operators:
  switchMap     — cancel on new (search-as-you-type)
  exhaustMap    — ignore while busy (submit button)
  forkJoin      — parallel calls, wait for all
  combineLatest — reactive filters
  debounceTime  — pause before emitting
  takeUntilDestroyed — auto-unsubscribe (Angular 16+)