Skip to main content
Angular HTTP Client

Module Overview

Estimated Time: 4-5 hours | Difficulty: Intermediate | Prerequisites: Module 7
Angular’s HttpClient provides a powerful API for making HTTP requests while RxJS enables reactive data handling. Together they form the foundation of Angular’s data layer. What You’ll Learn:
  • 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

// 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

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

// 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:
┌─────────────────────────────────────────────────────────────────────────┐
│                    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

// 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

// 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

// 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

// 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

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

@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

@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

@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

@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

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

@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:
  1. Fetches paginated data from API
  2. Has search with debounce
  3. Supports sorting
  4. Shows loading and error states
  5. Uses proper RxJS operators
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