Back to blog
angularintermediate

Angular HTTP Client & RxJS Essentials

Make API calls with HttpClient, handle errors gracefully, write HTTP interceptors for auth and logging, and master the RxJS operators you'll use every day.

LearnixoApril 16, 20266 min read
AngularHttpClientRxJSObservablesInterceptorsIntermediate
Share:𝕏

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+)

Enjoyed this article?

Explore the learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.