Angular – Talking to an API with HttpClient

July 15, 20263 min readUpdated 8/21/2026

Every real application is mostly API calls. Angular ships a client for them, and it is provided once at bootstrap:

provideHttpClient(withFetch(), withInterceptors([apiInterceptor])),

withFetch() puts it on the Fetch API rather than XMLHttpRequest. withInterceptors() takes the functions every request passes through — the next lesson.

The basic shape

get<T>(path: string): Observable<T> {
  return this.http.get<T>(path);
}

post<T>(path: string, body?: unknown): Observable<T> {
  return this.http.post<T>(path, body ?? null);
}

Two things to notice, because both differ from fetch.

It returns an observable, not a promise. Nothing is sent until something subscribes — an observable is cold. A get() whose result is discarded makes no request at all, which is a genuine "why is nothing happening" moment the first time.

JSON is parsed for you, and typed by the generic. There is no await res.json() step, and no res.ok check either: a non-2xx status is an error on the observable rather than a successful response you have to inspect. That last point is the one people miss when moving from fetch.

Getting a value out

Three ways, and the app uses all three deliberately.

firstValueFrom when the calling code is already async:

const [addresses, methods] = await Promise.all([
  firstValueFrom(this.profileApi.listAddresses()),
  firstValueFrom(this.profileApi.listPaymentMethods()),
]);

Two independent requests, so they run concurrently rather than one after the other.

toSignal when the result should drive a template — covered in the RxJS lesson. | async in the template, which is the historical way and which this app never uses.

httpResource

This is the interesting one, and it is what the menu is built on:

private readonly productsResource = httpResource<Product[]>(() => '/api/products', {
  defaultValue: [],
});

It gives back signals for the value, the loading state and the error; it cancels an in-flight request when the URL changes; and it re-fetches on reload(). The React app's MenuContext writes all of that by hand — a loading flag, an error branch, an AbortController, and a cleanup function — and its own comment says this is where a data library would normally go. Angular ships the library.

The URL is a function, not a string, and that is the point: read a signal inside it and the resource re-fetches whenever that signal changes. These three depend on nothing, so they fetch once and then only on demand.

⚠️ httpResource is marked @experimental in Angular 21. It is used here because it is exactly the concept the file exists to teach, and because this app pins its versions. Weigh that label before copying it into production.

The gotcha that cost a blank page

readonly products = computed(() =>
  this.productsResource.hasValue() ? this.productsResource.value() : [],
);

Reading resource.value() THROWS while the resource is in an error state — a ResourceValueError — even with defaultValue: [] set. The default covers loading and idle, not failure.

Left unguarded, a backend that is down does not render the carefully computed "Could not load the menu" alert; it throws inside the template and blanks the page. hasValue() is the guard, and it is why these are computed rather than the resource's own signal.

Deriving loading and error

readonly loading = computed(
  () =>
    this.productsResource.isLoading() ||
    this.toppingsResource.isLoading() ||
    this.crustsResource.isLoading(),
);

Three requests, fired concurrently — Promise.all without writing one — and the screen is "loading" until all three land, because a menu with crusts but no pizzas is not a menu anyone can use.

Query parameters

HttpClient takes a params option, which encodes for you:

this.http.get<Product[]>('/api/products', { params: { type: 'PIZZA' } });

Building the string by hand is fine when it is one value and you encode it, as the menu search does with encodeURIComponent. For anything conditional, the options object is safer.

Where error handling goes

Not here. Every request in this app passes through one interceptor that converts failures into a single ApiError type, so no service or component unpicks Angular's transport wrapper. That is the next lesson.