Angular Basics #7: Calling APIs with HttpClient

7 min read

Last time, we learned how to compose multi-page apps with the Router. One last piece of the puzzle remains. Once you’ve built a screen and wired up routing, you ultimately need to fetch data from a backend server for it to feel like a real app. This time, we’ll cover Angular’s standard communication tool, HttpClient.

HttpClient setup #

Instead of fetch or axios, Angular recommends its own HTTP client, HttpClient. Interceptors, testing utilities, and RxJS integration come bundled together, and it works naturally with Angular SSR (server-side rendering) too.

First, add one line to the app config. With the standalone pattern, register provideHttpClient() in app.config.ts.

src/app/app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';

import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideHttpClient(),
  ],
};

This single line lets you inject HttpClient anywhere in the app.

Note
The old way of importing HttpClientModule is the NgModule-era pattern. For new projects, use provideHttpClient(). It’s the standard for the standalone era.

GET requests #

Let’s start with the most basic GET request. We’ll create a service that fetches a list of users.

src/app/user.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';

export interface User {
  id: number;
  name: string;
  email: string;
}

@Injectable({ providedIn: 'root' })
export class UserService {
  private http = inject(HttpClient);
  private apiUrl = 'https://jsonplaceholder.typicode.com/users';

  getUsers(): Observable<User[]> {
    return this.http.get<User[]>(this.apiUrl);
  }
}

Three points to notice.

  1. We inject HttpClient with inject(HttpClient). Constructor injection (constructor(private http: HttpClient)) still works, but recent Angular code prefers the inject() functional injection.
  2. Specify the response type as a generic with get<User[]>(url). Then the return type of getUsers() is inferred as Observable<User[]>, so you can handle it type-safely.
  3. HttpClient’s methods don’t send the request immediately — they return an Observable. The request goes out only when someone subscribes.

Handling Observables #

Let’s take the Observable returned by the service and draw it on the screen from the component. The simplest way is to receive the result directly with subscribe.

src/app/user-list.component.ts
import { Component, OnInit, inject, signal } from '@angular/core';
import { UserService, User } from './user.service';

@Component({
  selector: 'app-user-list',
  standalone: true,
  template: `
    <ul>
      @for (user of users(); track user.id) {
        <li>{{ user.name }} ({{ user.email }})</li>
      }
    </ul>
  `,
})
export class UserListComponent implements OnInit {
  private userService = inject(UserService);
  users = signal<User[]>([]);

  ngOnInit() {
    this.userService.getUsers().subscribe(data => {
      this.users.set(data);
    });
  }
}

We put the data received in the subscribe callback into the signal, and the template calls users() to update automatically.

That said, when using subscribe directly, watch out for memory leaks. If the subscription outlives the component, it might try to update state on a component that no longer exists, causing errors or leaks. We’ll address this properly in the next series (Intermediate), but there’s a better pattern you can use right now.

Convert Observable → Signal with toSignal #

Introduced in Angular 16, toSignal automatically converts an Observable into a signal. You don’t have to worry about unsubscribing — when the component is destroyed, it cleans up on its own — and in the template you just call it like a signal.

src/app/user-list.component.ts
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { UserService } from './user.service';

@Component({
  selector: 'app-user-list',
  standalone: true,
  template: `
    @if (users(); as list) {
      <ul>
        @for (user of list; track user.id) {
          <li>{{ user.name }} ({{ user.email }})</li>
        }
      </ul>
    } @else {
      <p>Loading...</p>
    }
  `,
})
export class UserListComponent {
  private userService = inject(UserService);
  users = toSignal(this.userService.getUsers());
}

subscribe is gone, and OnInit is no longer needed. toSignal handles subscribing and unsubscribing internally. It returns undefined initially, so you can branch on a loading state with @if (users(); as list) as above.

Tip
When writing new code, prefer toSignal first. It has less leak risk than direct subscribe, and pairs well with signal-based templates.

POST/PUT/DELETE requests #

Read-only apps are rare. Methods to create, update, and delete data follow the same shape.

src/app/user.service.ts
// Create
createUser(user: Omit<User, 'id'>): Observable<User> {
  return this.http.post<User>(this.apiUrl, user);
}

// Full update
updateUser(user: User): Observable<User> {
  return this.http.put<User>(`${this.apiUrl}/${user.id}`, user);
}

// Delete
deleteUser(id: number): Observable<void> {
  return this.http.delete<void>(`${this.apiUrl}/${id}`);
}

The second argument of post and put is the request body. HttpClient handles JSON serialization for you. The caller receives an Observable and processes it just like a GET.

From a component
this.userService
  .createUser({ name: 'Cheolsu', email: 'cheolsu@example.com' })
  .subscribe(created => console.log('Created:', created));

Error handling #

Don’t assume the network always works. Angular recommends intercepting errors with the RxJS catchError operator.

src/app/user.service.ts
import { catchError, EMPTY } from 'rxjs';

getUsers(): Observable<User[]> {
  return this.http.get<User[]>(this.apiUrl).pipe(
    catchError(err => {
      console.error('Failed to fetch users:', err);
      return EMPTY;
    })
  );
}

pipe is the channel that lines up operators on an Observable. catchError must return a new Observable to swap into the original flow when an error happens. As in the example, returning EMPTY (which sends no value and terminates immediately) makes it look to the subscriber as if no data arrived.

If you want to flow a default value instead, use of([]).

import { of } from 'rxjs';

return this.http.get<User[]>(this.apiUrl).pipe(
  catchError(() => of([])) // fallback to an empty array on error
);

Headers and options #

Often, you need to send an auth token to an API or attach query parameters. Pass an options object as the second (or third) argument of HttpClient.

Headers and query params
import { HttpHeaders, HttpParams } from '@angular/common/http';

getPosts(userId: number, token: string): Observable<Post[]> {
  const headers = new HttpHeaders({
    Authorization: `Bearer ${token}`,
  });

  const params = new HttpParams().set('userId', userId);

  return this.http.get<Post[]>('/api/posts', { headers, params });
}

A shorter form, just passing an object instead of HttpParams, is also possible.

this.http.get<Post[]>('/api/posts', {
  params: { userId, limit: 10 },
});

Interceptor in one line #

Attaching the token by hand on every request is tedious. Angular provides Interceptors that intercept all HTTP requests and let you inject common processing in one place.

src/app/auth.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const token = localStorage.getItem('token');
  if (!token) return next(req);

  const cloned = req.clone({
    setHeaders: { Authorization: `Bearer ${token}` },
  });
  return next(cloned);
};

Register it in app.config.ts and you’re done.

src/app/app.config.ts
provideHttpClient(withInterceptors([authInterceptor])),

Request logging, token refresh, common error handling, caching — all of it gets organized in one place via interceptors. Detailed patterns are coming in the Intermediate series, so for now just remember that “this kind of thing exists.”

Recap #

This post covered using HttpClient to talk to the backend.

  • Set up with provideHttpClient() and inject with inject(HttpClient)
  • get<T> / post<T> / put<T> / delete<T> — type-safe with generics
  • The result is an Observable. Either subscribe directly or convert with toSignal to handle as a signal
  • catchError + EMPTY / of for error fallback
  • HttpHeaders, HttpParams, or a plain object for headers and query params
  • Interceptors gather common processing (auth, logging, errors) in one place

That’s the final post of the Angular Basics series. Starting from “what Angular is” in #1, through component and template syntax, data binding, Directives and Pipes, Service and dependency injection, Router, and today’s HttpClient — we’ve covered every core tool needed to build a small Angular app end to end. You now have the fundamentals to fetch data and render it, handle user input, and navigate between multiple pages in Angular.

The next step is the “Angular Intermediate” series. We’ll dig into topics intentionally postponed in the basics.

  • Reactive Forms — model-based forms that handle large forms, validation, and dynamic forms cleanly
  • RxJS in depth — frequently used operators like switchMap, debounceTime, and combineLatest, plus real-world patterns for search, autocomplete, and retry
  • Lifecycle and Change Detection — beyond OnInit, the OnPush strategy, and the relationship between signals and Zone.js
  • HTTP Interceptor patterns — token refresh, caching, common error handling
  • Standalone Routing in depth — Lazy loading, Route Guards, Resolver, route data

If the basics were “how to use the tools,” the intermediate is the territory of “how to actually build a product with those tools.” Thank you for sticking through to the end of the basics. I’ll see you again in the Intermediate series for deeper conversation.

X