Angular HttpClient: Typed Requests, Interceptors, and Signals for Server Data
Every real Angular app is mostly a conversation with a backend. Angular ships its own HTTP layer โ HttpClient โ rather than leaving you with raw fetch, and the additions justify themselves quickly: typed responses, interceptors (the middleware that ends per-call auth/error boilerplate), first-class testing support, and integration with the frameworkโs reactivity.
This guide covers the modern usage: making requests, the interceptor patterns every production app needs, getting server data into signals cleanly, and the loading/error-state discipline that separates polished apps from ones that flash and lie.
Setup and the Shape of Requests
Provide once, inject anywhere:
provideHttpClient(withInterceptors([authInterceptor, errorInterceptor]))@Injectable({ providedIn: 'root' })export class ProductApi { private http = inject(HttpClient); private base = inject(API_CONFIG).baseUrl;
getProduct(id: string) { return this.http.get<Product>(`${this.base}/products/${id}`); } search(q: string, page: number) { return this.http.get<Page<Product>>(`${this.base}/products`, { params: { q, page }, // encoded safely, like URLSearchParams }); } create(draft: ProductDraft) { return this.http.post<Product>(`${this.base}/products`, draft); // JSON automatic }}Reading the idiom: HTTP calls live in API services, not components โ one place per backend area, injected by stores and pages. The type parameter (get<Product>) types the parsed body throughout your code โ with the standing caveat that itโs a compile-time assertion, not runtime validation; the server can still send anything, so validate at trust boundaries when stakes are high. JSON serialization, content-type headers, and query encoding are automatic โ the fetch rituals handled.
Two behaviors inherited from RxJS that you must know even before the full RxJS guide:
- Requests return Observables, and Observables are lazy โ
this.api.getProduct('42')alone sends nothing; the request fires on subscription (subscribe(),asyncpipe, or conversion to a signal). The classic โmy POST never happenedโ bug is an unsubscribed observable. - HttpClient observables complete after one response โ no unsubscribe management needed for single-shot requests, and errors arrive on the error channel, not as HTTP-status-checking on a response object: a 404 is an error to HttpClient, unlike fetch. One less trap.
Interceptors: The Middleware That Ends Boilerplate
Interceptors are functions wrapping every request/response โ auth, errors, logging, retries, declared once:
export const authInterceptor: HttpInterceptorFn = (req, next) => { const token = inject(AuthStore).token(); if (!token) return next(req); return next(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }));};export const errorInterceptor: HttpInterceptorFn = (req, next) => { const toast = inject(ToastService); const router = inject(Router);
return next(req).pipe( catchError((err: HttpErrorResponse) => { if (err.status === 401) router.navigate(['/login']); else if (err.status >= 500) toast.error('Server error โ please retry.'); return throwError(() => err); // still propagate to the caller }), );};Note the details that make these production-shaped: requests are immutable โ req.clone(...) is mandatory, the immutability discipline applied to HTTP; interceptors run in an injection context (inject() works โ the pattern again); they chain in registration order; and the error interceptor rethrows after global handling, so call sites can still respond locally โ a global handler that swallows errors reinvents the swallowed-exception antipattern at HTTP scale.
The standard production set, each ~15 lines: auth-header attachment, global error routing (401 โ login, 5xx โ toast, with local propagation), base-URL prefixing, and retry-with-backoff for idempotent GETs (same retry judgment as ever โ never blind-retry POSTs). This layer is why Angular codebases donโt have a hand-rolled fetch wrapper: the framework provided the seams.
Server Data as Signals: The Modern Wiring
Components want signals; HttpClient speaks Observable. Three bridges, by situation:
httpResource / resource โ for โload data keyed on reactive paramsโ:
export class ProductDetailPage { id = input.required<string>(); // from the route
product = httpResource<Product>(() => `/api/products/${this.id()}`); // product.value() | product.isLoading() | product.error() โ all signals}The declarative endgame: the URL is a function of signals; when id changes, the resource cancels the stale request and fetches the new one โ the route-param reload problem and the stale-response race solved in one primitive, with loading/error signals built in.
toSignal โ for wrapping an existing observable once:
user = toSignal(this.auth.user$, { initialValue: null });Plain subscribe โ for imperative actions: form submissions, deletes โ where the act happens on user command and you handle the outcome:
placeOrder() { this.submitting.set(true); this.api.create(this.draft()).subscribe({ next: (order) => this.router.navigate(['/orders', order.id]), error: () => this.submitting.set(false), });}The division: reads are declarative (resources derived from state โ URLs, inputs, filters), writes are imperative (subscribed commands). Apps that imperatively fetch reads accumulate the loading/race/staleness bugs the declarative layer exists to prevent.
Loading and Error States: The Discipline
Every remote read has at least four states, and honest UIs render all of them:
@if (product.isLoading()) { <app-skeleton-card />} @else if (product.error()) { <app-error-panel message="Couldn't load this product." (retry)="product.reload()" />} @else if (product.value(); as p) { <app-product-view [product]="p" />} @else { <p>Product not found.</p>}This @switch-shaped rendering (control flow guide) is the pattern to make reflexive. The failure modes it prevents are the ones users actually meet: infinite spinners (error state never rendered), blank sections (loading never rendered), and the lying UI that shows stale data during a failed refresh. Two refinements for polish: keep previous data visible during refetches (resources expose status for exactly this), and distinguish โempty resultโ from โerrorโ โ a search with no hits is a state, not a failure.
For writes, the equivalent discipline: disable the submit control while in flight (the double-submit race), surface failure with a retry path, and on success update local state from the response (the server may have normalized/defaulted fields) rather than assuming the draft round-tripped unchanged.
A Complete Feature Slice, End to End
The guideโs pieces assembled into the shape youโll actually build โ an orders list with filter, load, and a mutation:
// order-api.ts โ the HTTP boundary, and nothing else@Injectable({ providedIn: 'root' })export class OrderApi { private http = inject(HttpClient);
list(status: string) { return this.http.get<Order[]>('/api/orders', { params: { status } }); } cancel(id: string) { return this.http.post<Order>(`/api/orders/${id}/cancel`, {}); }}
// orders-page.ts โ declarative read, imperative writeexport class OrdersPage { private api = inject(OrderApi);
status = signal<'all' | 'pending' | 'shipped'>('pending');
orders = rxResource({ params: () => ({ status: this.status() }), stream: ({ params }) => this.api.list(params.status), });
cancelling = signal<string | null>(null);
cancel(id: string) { this.cancelling.set(id); this.api.cancel(id).subscribe({ next: () => { this.cancelling.set(null); this.orders.reload(); }, error: () => this.cancelling.set(null), }); }}Trace the flows and the guideโs rules light up: changing status re-runs the resource (cancelling any stale request); the cancel action is a subscribed command with its own in-flight state (disabling exactly the row being cancelled โ [disabled]="cancelling() === o.id"); success triggers reload() so the list reflects server truth rather than a guessed local mutation. Global concerns โ the auth header, 401 handling, 5xx toasts โ appear nowhere, because interceptors own them. When the list logic grows (client-side caching, optimistic updates, cross-page sharing), this component-level wiring graduates into a store service with the identical internal shape โ the migration is a cut-and-paste, which is the sign the layering was right.
Note the two resource flavors while reading code in the wild: httpResource for direct URL-from-signals GETs, and rxResource/resource when the loader is an existing observable- or promise-returning API method, as above. Same semantics โ reactive params, cancellation, value/isLoading/error signals โ different loader plumbing.
Frequently Asked Questions
Why not just use fetch? You can โ but youโd rebuild interceptors (auth on every call), typed wrappers, cancellation-on-param-change, and the testing story: provideHttpClientTesting lets tests assert โone GET to /products/42, respond with Xโ declaratively โ the whole HTTP layer becomes substitutable at the DI seam. The framework already built your fetch wrapper, better.
Where does caching go? Layered: HTTP-level caching belongs to the serverโs headers; app-level, a store service caching results in signals (with explicit invalidation on writes) is the standard shape. Request deduplication โ concurrent identical GETs sharing one flight โ fits neatly in an interceptor or the promise-caching patternโs observable cousin (shareReplay).
How do I upload files / track progress? http.post(url, formData) handles FormData naturally; pass { reportProgress: true, observe: 'events' } and the observable emits progress events โ one of the places HttpClient beats fetch outright.
Cancellation? Resources cancel superseded requests automatically. Manual flows: unsubscribing cancels the underlying request โ RxJSโs switchMap builds search-box-style โlatest winsโ on that primitive.
Can I type responses more safely than get<T>? Yes โ the layered options: schema validation (zod/valibot) at the API-service boundary parses and types in one step, turning contract drift into immediate, located errors; or generated clients from OpenAPI specs, which keep types mechanically synced with the backend. Both concentrate at the API-service layer this guide argued for โ one more payoff of not scattering HTTP calls through components.
How do interceptors handle token refresh? The canonical hard case: on a 401, pause the failing request, call the refresh endpoint (once โ concurrent 401s must share the refresh, the request-deduplication pattern), then replay the original with the new token; if refresh fails, logout. Itโs ~30 lines of catchError + switchMap and every production app has one โ worth writing once, carefully, with tests for the concurrent case.
What error object do I get? HttpErrorResponse โ status, message, and the serverโs error body in .error (donโt discard it; servers put the useful message there). Status 0 means the request never got a response โ network/CORS territory, worth distinguishing in global handling.
Testing the HTTP Layer
The testing story deserves its promised section, because itโs genuinely excellent. provideHttpClientTesting swaps the backend for a controller you script:
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()],});const httpMock = TestBed.inject(HttpTestingController);const api = TestBed.inject(OrderApi);
api.list('pending').subscribe((orders) => expect(orders.length).toBe(2));
const req = httpMock.expectOne('/api/orders?status=pending');expect(req.request.method).toBe('GET');req.flush([order1, order2]); // respondhttpMock.verify(); // fails the test if unexpected requests happenedThree properties make this better than fetch-mocking gymnastics: assertions are about requests (URL, method, headers, body โ the actual contract), verify() catches requests you didnโt expect (the duplicate-call bug class, caught in unit tests), and error paths are scriptable (req.flush(msg, { status: 500, statusText: 'Server Error' })) so error-state rendering gets tested without a flaky backend. Interceptors run in these tests too โ auth-header attachment is assertable per request. One layer up, components under test fake the API service entirely and never touch HTTP โ each layer tested at its own seam.
Self-Check
this.api.delete(id);โ the DELETE never reaches the server. Why?- Where does the auth token get attached โ name the mechanism and why per-call header code is wrong.
- A product page uses
httpResourcekeyed on the route id. The user clicks through products rapidly. What two bugs are prevented versus naivengOnInitfetching? - Your error interceptor toasts on 5xx and returns
of(null)instead of rethrowing. What breaks downstream? - Which belongs where: (a) โload orders listโ, (b) โsubmit refundโ, (c) โcurrent user from auth eventsโ โ resource / subscribe / toSignal?
(Answers: no subscription โ HttpClient observables are lazy; subscribe (or make it a resource/await its firstValueFrom). Auth interceptor via withInterceptors โ per-call headers duplicate the concern everywhere and will be forgotten somewhere. Stale-response races (old response overwriting new) and no-reload-on-param-change; the resource cancels and refetches per id. Call sites see a successful null โ components render empty instead of error states, and local error handling becomes impossible; rethrow after global handling. (a) resource, (b) subscribe, (c) toSignal.)
And the architectural sentence to retain from this guide, since everything else hangs off it: components never see HTTP โ they see stores and resources; stores see typed API services; API services see HttpClient; interceptors see everything. Each seam is independently testable and independently replaceable, which is the entire reason the layers exist.
Next: Forms โ Angularโs two form systems, why reactive forms win for real applications, validation, and typed form models.