Angular’s HttpClient is the module for making HTTP requests to REST APIs. It returns typed Observable<T> responses, integrates with RxJS operators, and handles JSON serialisation automatically. Every HttpClient method returns a cold Observable — the HTTP request is not sent until the Observable is subscribed to. This means you can compose, transform, and cancel requests before execution, making HttpClient highly flexible for building a typed API client layer.
HttpClient Fundamentals
// ── app.config.ts ─────────────────────────────────────────────────────────
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(
withInterceptors([authInterceptor, errorInterceptor]),
withFetch() // Angular 18: use the Fetch API instead of XMLHttpRequest
),
],
};
// ── Posts API service ─────────────────────────────────────────────────────
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams, HttpHeaders } from '@angular/common/http';
import { Observable } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class PostsApiService {
private http = inject(HttpClient);
private baseUrl = inject(API_BASE_URL);
// ── GET — typed response ──────────────────────────────────────────────
getPublished(page = 1, size = 10, category = ''): Observable<PagedResult<PostSummaryDto>> {
const params = new HttpParams()
.set('page', page)
.set('size', size)
.set('category', category); // HttpParams handles URL encoding
return this.http.get<PagedResult<PostSummaryDto>>(
`${this.baseUrl}/api/posts`,
{ params }
);
// Observable is COLD — request sent only when subscribed
}
getBySlug(slug: string): Observable<PostDto> {
return this.http.get<PostDto>(`${this.baseUrl}/api/posts/by-slug/${slug}`);
}
// ── POST — send body, get typed response ─────────────────────────────
create(request: CreatePostRequest): Observable<PostDto> {
return this.http.post<PostDto>(`${this.baseUrl}/api/posts`, request);
// HttpClient serialises 'request' to JSON automatically
// Sets Content-Type: application/json automatically
}
// ── PUT — full replacement ────────────────────────────────────────────
replace(id: number, request: ReplacePostRequest): Observable<PostDto> {
return this.http.put<PostDto>(`${this.baseUrl}/api/posts/${id}`, request);
}
// ── PATCH — partial update ────────────────────────────────────────────
update(id: number, request: UpdatePostRequest): Observable<PostDto> {
return this.http.patch<PostDto>(`${this.baseUrl}/api/posts/${id}`, request);
}
// ── DELETE — no response body ─────────────────────────────────────────
delete(id: number): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/api/posts/${id}`);
}
// ── Access full HTTP response (status, headers, body) ────────────────
createWithHeaders(request: CreatePostRequest): Observable<HttpResponse<PostDto>> {
return this.http.post<PostDto>(
`${this.baseUrl}/api/posts`,
request,
{ observe: 'response' } // returns HttpResponse<PostDto> not just PostDto
);
// response.status → 201
// response.headers → HttpHeaders (Location: /api/posts/42)
// response.body → PostDto
}
}
HttpClient methods return cold Observables — the HTTP request is not sent when you call http.get(). The request is sent when something subscribes to the Observable (via subscribe(), the async pipe, firstValueFrom(), or an RxJS operator). This is why assigning this.posts$ = http.get(...) in a constructor does nothing until the template uses {{ posts$ | async }} — the async pipe subscribes. If you call http.get() without subscribing, no network request is made.withFetch() in Angular 18’s provideHttpClient() to use the browser’s native Fetch API instead of XMLHttpRequest. The Fetch API has better HTTP/2 multiplexing, supports streaming responses, and has a simpler API. It is available in all modern browsers and is the default in Angular’s SSR mode. Adding withFetch() prepares the application for future Angular features and aligns the browser and SSR environments.`${this.baseUrl}/api/posts?slug=${userInput}` is vulnerable to injection if userInput contains characters like & or =. Use HttpParams which properly encodes all values: new HttpParams().set('slug', userInput) — the Params object handles URL encoding. For path segments, similarly avoid concatenation with unvalidated user input.HttpParams Builder Pattern
// ── Build complex query strings safely ────────────────────────────────────
search(query: PostSearchQuery): Observable<PagedResult<PostSummaryDto>> {
let params = new HttpParams()
.set('page', query.page ?? 1)
.set('size', query.size ?? 10);
// Only add optional params when they have values
if (query.search) params = params.set('search', query.search);
if (query.category) params = params.set('category', query.category);
if (query.tag) params = params.set('tag', query.tag);
if (query.sort) params = params.set('sort', query.sort);
return this.http.get<PagedResult<PostSummaryDto>>(
`${this.baseUrl}/api/posts`,
{ params }
);
// Generated URL: /api/posts?page=1&size=10&search=dotnet&category=tech
}
Common Mistakes
Mistake 1 — Not subscribing to an Observable (no HTTP request sent)
❌ Wrong — this.http.get('/api/posts') called without subscribe; no request is made.
✅ Correct — this.http.get('/api/posts').subscribe(data => ...) or use the async pipe in the template.
Mistake 2 — String concatenation for query parameters (URL encoding issues)
❌ Wrong — `/api/posts?q=${search}&page=${page}`; special characters in search break the URL.
✅ Correct — use new HttpParams().set('q', search).set('page', page); properly encodes all values.