Technology  /  Angular

๐Ÿ…ฐ๏ธ Angular 15 guides ยท updated 2026

Standalone components, signals, RxJS, and dependency injection โ€” building maintainable frontends with the framework enterprises trust.

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:

app.config.ts
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:

  1. Requests return Observables, and Observables are lazy โ€” this.api.getProduct('42') alone sends nothing; the request fires on subscription (subscribe(), async pipe, or conversion to a signal). The classic โ€œmy POST never happenedโ€ bug is an unsubscribed observable.
  2. 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 write
export 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]); // respond
httpMock.verify(); // fails the test if unexpected requests happened

Three 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

  1. this.api.delete(id); โ€” the DELETE never reaches the server. Why?
  2. Where does the auth token get attached โ€” name the mechanism and why per-call header code is wrong.
  3. A product page uses httpResource keyed on the route id. The user clicks through products rapidly. What two bugs are prevented versus naive ngOnInit fetching?
  4. Your error interceptor toasts on 5xx and returns of(null) instead of rethrowing. What breaks downstream?
  5. 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.