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.
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:
import { provideHttpClient, withInterceptors } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideHttpClient(),
],
};HttpClient Basics
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)
@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)
@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
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
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:
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { authInterceptor } from './interceptors/auth.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(withInterceptors([authInterceptor])),
],
};Retry Interceptor
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
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
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
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
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:
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:
// Observable approach
products$ = this.productService.getAll();
// Template: {{ products$ | async }}
// Signal approach (Angular 17+)
products = toSignal(this.productService.getAll(), { initialValue: [] });
// Template: {{ products() }} — no async pipe neededUse 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+) Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.