Module Overview
Estimated Time: 4-5 hours | Difficulty: Intermediate | Prerequisites: Module 7
- HttpClient setup and configuration
- Making HTTP requests (GET, POST, PUT, DELETE)
- HTTP interceptors for cross-cutting concerns
- Error handling strategies
- RxJS operators for data transformation
- Caching and request management
HttpClient Setup
Copy
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient, withInterceptors, withFetch } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(
withFetch(), // Use fetch API instead of XMLHttpRequest
withInterceptors([ // Add interceptors
authInterceptor,
loggingInterceptor,
errorInterceptor
])
)
]
};
Making HTTP Requests
Basic CRUD Operations
Copy
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams, HttpHeaders } from '@angular/common/http';
import { Observable } from 'rxjs';
export interface Product {
id: number;
name: string;
price: number;
category: string;
}
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
pageSize: number;
}
@Injectable({ providedIn: 'root' })
export class ProductService {
private http = inject(HttpClient);
private apiUrl = '/api/products';
// GET all products
getProducts(): Observable<Product[]> {
return this.http.get<Product[]>(this.apiUrl);
}
// GET with query parameters
getProductsPaginated(
page: number,
pageSize: number,
category?: string
): Observable<PaginatedResponse<Product>> {
let params = new HttpParams()
.set('page', page.toString())
.set('pageSize', pageSize.toString());
if (category) {
params = params.set('category', category);
}
return this.http.get<PaginatedResponse<Product>>(this.apiUrl, { params });
}
// GET single product
getProduct(id: number): Observable<Product> {
return this.http.get<Product>(`${this.apiUrl}/${id}`);
}
// POST - create new product
createProduct(product: Omit<Product, 'id'>): Observable<Product> {
return this.http.post<Product>(this.apiUrl, product);
}
// PUT - update product
updateProduct(id: number, product: Partial<Product>): Observable<Product> {
return this.http.put<Product>(`${this.apiUrl}/${id}`, product);
}
// PATCH - partial update
patchProduct(id: number, updates: Partial<Product>): Observable<Product> {
return this.http.patch<Product>(`${this.apiUrl}/${id}`, updates);
}
// DELETE
deleteProduct(id: number): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
}
}
Request Options
Copy
// Custom headers
const headers = new HttpHeaders({
'Content-Type': 'application/json',
'X-Custom-Header': 'value'
});
this.http.get<Product[]>(this.apiUrl, { headers });
// Observe response (headers, status, etc.)
this.http.get<Product[]>(this.apiUrl, { observe: 'response' })
.subscribe(response => {
console.log('Status:', response.status);
console.log('Headers:', response.headers.get('X-Total-Count'));
console.log('Body:', response.body);
});
// Get response as text
this.http.get(this.apiUrl, { responseType: 'text' });
// Get response as blob (for files)
this.http.get(this.apiUrl + '/export', { responseType: 'blob' });
// Track upload/download progress
this.http.post(this.apiUrl, formData, {
reportProgress: true,
observe: 'events'
}).subscribe(event => {
if (event.type === HttpEventType.UploadProgress) {
const progress = Math.round(100 * event.loaded / event.total!);
console.log(`Upload: ${progress}%`);
}
});
HTTP Interceptors
Interceptors handle cross-cutting concerns:Copy
┌─────────────────────────────────────────────────────────────────────────┐
│ Interceptor Chain │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ Request Flow: │
│ ┌────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Component │ ──▶ │ Auth Inject. │ ──▶ │ Log Inject. │ ──▶ Server │
│ └────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ Response Flow: │
│ ┌────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Component │ ◀── │ Auth Inject. │ ◀── │ Log Inject. │ ◀── Server │
│ └────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ Common Use Cases: │
│ • Add auth tokens to requests │
│ • Log requests/responses │
│ • Handle errors globally │
│ • Add caching │
│ • Transform responses │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Auth Interceptor
Copy
// interceptors/auth.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthService } from '../services/auth.service';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const authService = inject(AuthService);
const token = authService.getToken();
// Skip auth for public endpoints
if (req.url.includes('/public/')) {
return next(req);
}
if (token) {
const authReq = req.clone({
headers: req.headers.set('Authorization', `Bearer ${token}`)
});
return next(authReq);
}
return next(req);
};
Error Interceptor
Copy
// interceptors/error.interceptor.ts
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { catchError, throwError } from 'rxjs';
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
const router = inject(Router);
const toastService = inject(ToastService);
return next(req).pipe(
catchError((error: HttpErrorResponse) => {
let errorMessage = 'An error occurred';
if (error.status === 0) {
errorMessage = 'Network error. Please check your connection.';
} else if (error.status === 401) {
errorMessage = 'Session expired. Please login again.';
router.navigate(['/login']);
} else if (error.status === 403) {
errorMessage = 'You do not have permission to access this resource.';
} else if (error.status === 404) {
errorMessage = 'Resource not found.';
} else if (error.status >= 500) {
errorMessage = 'Server error. Please try again later.';
} else if (error.error?.message) {
errorMessage = error.error.message;
}
toastService.showError(errorMessage);
return throwError(() => error);
})
);
};
Logging Interceptor
Copy
// interceptors/logging.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { tap, finalize } from 'rxjs';
export const loggingInterceptor: HttpInterceptorFn = (req, next) => {
const startTime = Date.now();
console.log(`🚀 ${req.method} ${req.url}`);
return next(req).pipe(
tap({
next: (event) => {
if (event.type === HttpEventType.Response) {
console.log(`✅ ${req.url} - ${event.status}`);
}
},
error: (error) => {
console.error(`❌ ${req.url} - ${error.status}`);
}
}),
finalize(() => {
const duration = Date.now() - startTime;
console.log(`⏱️ ${req.url} took ${duration}ms`);
})
);
};
Caching Interceptor
Copy
// interceptors/cache.interceptor.ts
import { HttpInterceptorFn, HttpResponse } from '@angular/common/http';
import { of, tap } from 'rxjs';
const cache = new Map<string, HttpResponse<unknown>>();
export const cacheInterceptor: HttpInterceptorFn = (req, next) => {
// Only cache GET requests
if (req.method !== 'GET') {
return next(req);
}
// Check if request should be cached
if (!req.headers.has('X-Cache')) {
return next(req);
}
const cacheKey = req.urlWithParams;
const cachedResponse = cache.get(cacheKey);
if (cachedResponse) {
console.log(`📦 Cache hit: ${cacheKey}`);
return of(cachedResponse.clone());
}
return next(req).pipe(
tap(event => {
if (event instanceof HttpResponse) {
console.log(`💾 Caching: ${cacheKey}`);
cache.set(cacheKey, event.clone());
}
})
);
};
RxJS Operators
Common Operators for HTTP
Copy
import {
map, filter, tap, catchError, retry, retryWhen,
switchMap, mergeMap, concatMap, exhaustMap,
debounceTime, distinctUntilChanged, throttleTime,
takeUntil, take, first, share, shareReplay
} from 'rxjs/operators';
import { of, throwError, timer, Subject } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class DataService {
private http = inject(HttpClient);
// Transform data
getUserNames(): Observable<string[]> {
return this.http.get<User[]>('/api/users').pipe(
map(users => users.map(u => u.name))
);
}
// Filter results
getActiveUsers(): Observable<User[]> {
return this.http.get<User[]>('/api/users').pipe(
map(users => users.filter(u => u.active))
);
}
// Side effects (logging, analytics)
getProducts(): Observable<Product[]> {
return this.http.get<Product[]>('/api/products').pipe(
tap(products => console.log(`Fetched ${products.length} products`)),
tap(products => this.analytics.track('products_fetched', { count: products.length }))
);
}
// Retry on failure
getDataWithRetry(): Observable<Data> {
return this.http.get<Data>('/api/data').pipe(
retry(3), // Retry 3 times on failure
catchError(error => {
console.error('All retries failed');
return of({ fallback: true } as Data);
})
);
}
// Exponential backoff retry
getDataWithBackoff(): Observable<Data> {
return this.http.get<Data>('/api/data').pipe(
retryWhen(errors =>
errors.pipe(
mergeMap((error, index) => {
const retryAttempt = index + 1;
if (retryAttempt > 3) {
return throwError(() => error);
}
console.log(`Retry attempt ${retryAttempt}: waiting ${retryAttempt * 1000}ms`);
return timer(retryAttempt * 1000);
})
)
)
);
}
// Share results (avoid duplicate HTTP calls)
private products$ = this.http.get<Product[]>('/api/products').pipe(
shareReplay(1) // Cache and replay last value
);
getProducts(): Observable<Product[]> {
return this.products$; // Multiple subscribers = 1 HTTP call
}
}
SwitchMap vs MergeMap vs ConcatMap vs ExhaustMap
Copy
@Component({
selector: 'app-search',
template: `
<input (input)="onSearch($event)" placeholder="Search..." />
<ul>
@for (result of results(); track result.id) {
<li>{{ result.name }}</li>
}
</ul>
`
})
export class SearchComponent {
private searchSubject = new Subject<string>();
results = signal<SearchResult[]>([]);
constructor() {
// switchMap: Cancel previous request when new one comes
// Best for search/autocomplete
this.searchSubject.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(term => this.searchService.search(term))
).subscribe(results => this.results.set(results));
}
onSearch(event: Event) {
const term = (event.target as HTMLInputElement).value;
this.searchSubject.next(term);
}
}
/*
switchMap: Use when only latest matters (search, navigation)
User types: a -> ab -> abc
Requests: a(cancelled) -> ab(cancelled) -> abc(completed)
mergeMap: Use when all requests should complete (logging, analytics)
Requests run in parallel, complete in any order
concatMap: Use when order matters (queue processing)
Requests run sequentially, one after another
exhaustMap: Use to prevent double-clicks (form submission, login)
Ignore new requests while current is in progress
*/
Error Handling Patterns
Service Level Error Handling
Copy
@Injectable({ providedIn: 'root' })
export class UserService {
private http = inject(HttpClient);
getUser(id: number): Observable<User> {
return this.http.get<User>(`/api/users/${id}`).pipe(
catchError(this.handleError<User>('getUser'))
);
}
private handleError<T>(operation = 'operation') {
return (error: HttpErrorResponse): Observable<T> => {
console.error(`${operation} failed:`, error);
// Rethrow for component to handle
// return throwError(() => new Error(`${operation} failed: ${error.message}`));
// Or return safe fallback
return of(undefined as T);
};
}
}
Component Level Error Handling
Copy
@Component({
template: `
@if (loading()) {
<app-spinner />
} @else if (error()) {
<app-error
[message]="error()"
(retry)="loadData()"
/>
} @else {
<app-user-list [users]="users()" />
}
`
})
export class UsersComponent implements OnInit {
private userService = inject(UserService);
users = signal<User[]>([]);
loading = signal(false);
error = signal<string | null>(null);
ngOnInit() {
this.loadData();
}
loadData() {
this.loading.set(true);
this.error.set(null);
this.userService.getUsers().subscribe({
next: (users) => {
this.users.set(users);
this.loading.set(false);
},
error: (err) => {
this.error.set('Failed to load users. Please try again.');
this.loading.set(false);
}
});
}
}
Cancellation and Cleanup
Copy
@Component({...})
export class SearchComponent implements OnDestroy {
private destroy$ = new Subject<void>();
private searchTerms = new Subject<string>();
results = signal<Product[]>([]);
constructor() {
this.searchTerms.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(term => this.productService.search(term)),
takeUntil(this.destroy$) // Auto-unsubscribe on destroy
).subscribe(results => this.results.set(results));
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
search(term: string) {
this.searchTerms.next(term);
}
}
// Modern alternative using DestroyRef
@Component({...})
export class ModernSearchComponent {
private destroyRef = inject(DestroyRef);
constructor() {
this.searchTerms.pipe(
debounceTime(300),
switchMap(term => this.productService.search(term)),
takeUntilDestroyed(this.destroyRef)
).subscribe(results => this.results.set(results));
}
}
Signals with HTTP
Using toSignal
Copy
import { toSignal } from '@angular/core/rxjs-interop';
@Component({
template: `
<ul>
@for (product of products(); track product.id) {
<li>{{ product.name }}</li>
}
</ul>
`
})
export class ProductsComponent {
private productService = inject(ProductService);
// Convert Observable to Signal
products = toSignal(this.productService.getProducts(), {
initialValue: []
});
}
Reactive Data Loading
Copy
@Component({
template: `
<select (change)="setCategory($event)">
<option value="">All</option>
<option value="electronics">Electronics</option>
<option value="clothing">Clothing</option>
</select>
@if (loading()) {
<p>Loading...</p>
} @else {
@for (product of products(); track product.id) {
<app-product-card [product]="product" />
}
}
`
})
export class ProductListComponent {
private productService = inject(ProductService);
category = signal<string>('');
loading = signal(false);
products = toSignal(
toObservable(this.category).pipe(
tap(() => this.loading.set(true)),
switchMap(cat => this.productService.getByCategory(cat)),
tap(() => this.loading.set(false))
),
{ initialValue: [] }
);
setCategory(event: Event) {
const value = (event.target as HTMLSelectElement).value;
this.category.set(value);
}
}
Practice Exercise
Exercise: Build a Data Table with Pagination and Search
Create a data table component that:
- Fetches paginated data from API
- Has search with debounce
- Supports sorting
- Shows loading and error states
- Uses proper RxJS operators
Solution
Solution
Copy
interface TableState {
page: number;
pageSize: number;
search: string;
sortBy: string;
sortDir: 'asc' | 'desc';
}
@Component({
selector: 'app-data-table',
standalone: true,
imports: [ReactiveFormsModule],
template: `
<div class="table-controls">
<input
[formControl]="searchControl"
placeholder="Search..."
/>
<select (change)="setPageSize($event)">
<option value="10">10 per page</option>
<option value="25">25 per page</option>
<option value="50">50 per page</option>
</select>
</div>
@if (loading()) {
<div class="loading">Loading...</div>
} @else if (error()) {
<div class="error">
{{ error() }}
<button (click)="refresh()">Retry</button>
</div>
} @else {
<table>
<thead>
<tr>
<th (click)="sort('name')">
Name {{ sortIndicator('name') }}
</th>
<th (click)="sort('email')">
Email {{ sortIndicator('email') }}
</th>
<th (click)="sort('createdAt')">
Created {{ sortIndicator('createdAt') }}
</th>
</tr>
</thead>
<tbody>
@for (item of data()?.data; track item.id) {
<tr>
<td>{{ item.name }}</td>
<td>{{ item.email }}</td>
<td>{{ item.createdAt | date }}</td>
</tr>
} @empty {
<tr>
<td colspan="3">No results found</td>
</tr>
}
</tbody>
</table>
<div class="pagination">
<button
[disabled]="state().page === 1"
(click)="goToPage(state().page - 1)"
>
Previous
</button>
<span>
Page {{ state().page }} of {{ totalPages() }}
</span>
<button
[disabled]="state().page >= totalPages()"
(click)="goToPage(state().page + 1)"
>
Next
</button>
</div>
}
`
})
export class DataTableComponent {
private userService = inject(UserService);
searchControl = new FormControl('');
state = signal<TableState>({
page: 1,
pageSize: 10,
search: '',
sortBy: 'name',
sortDir: 'asc'
});
loading = signal(false);
error = signal<string | null>(null);
private refresh$ = new Subject<void>();
data = toSignal(
merge(
toObservable(this.state),
this.refresh$.pipe(map(() => this.state()))
).pipe(
tap(() => {
this.loading.set(true);
this.error.set(null);
}),
switchMap(state =>
this.userService.getUsers(state).pipe(
catchError(err => {
this.error.set('Failed to load data');
return of(null);
})
)
),
tap(() => this.loading.set(false))
)
);
totalPages = computed(() => {
const d = this.data();
if (!d) return 0;
return Math.ceil(d.total / this.state().pageSize);
});
constructor() {
// Debounced search
this.searchControl.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged(),
takeUntilDestroyed()
).subscribe(search => {
this.state.update(s => ({ ...s, search: search || '', page: 1 }));
});
}
sort(column: string) {
this.state.update(s => ({
...s,
sortBy: column,
sortDir: s.sortBy === column && s.sortDir === 'asc' ? 'desc' : 'asc',
page: 1
}));
}
sortIndicator(column: string): string {
const s = this.state();
if (s.sortBy !== column) return '';
return s.sortDir === 'asc' ? '▲' : '▼';
}
goToPage(page: number) {
this.state.update(s => ({ ...s, page }));
}
setPageSize(event: Event) {
const pageSize = +(event.target as HTMLSelectElement).value;
this.state.update(s => ({ ...s, pageSize, page: 1 }));
}
refresh() {
this.refresh$.next();
}
}
Summary
1
HttpClient
Use provideHttpClient() and inject HttpClient for API calls
2
Interceptors
Handle auth, logging, errors, and caching globally
3
RxJS Operators
Use switchMap for search, catchError for errors, shareReplay for caching
4
Error Handling
Handle errors at service and component levels with retry logic
5
Signals Integration
Use toSignal/toObservable for seamless reactive integration
Next Steps
Next: RxJS Deep Dive
Master advanced RxJS patterns for complex async scenarios