DEV Community

Atilla Baspinar
Atilla Baspinar

Posted on

Angular HTTP Client

Angular's HttpClient sends HTTP requests and returns RxJS Observables. Each request Observable emits exactly one value (the response) and then completes — or emits an error if the request fails.


1. Setup

Provide HttpClient globally in the app config. This is the recommended approach for standalone apps (Angular 15+).

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

export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(),
  ],
};
Enter fullscreen mode Exit fullscreen mode

HttpClientModule (the NgModule alternative) is deprecated since Angular 18. Use provideHttpClient() in new apps.


2. GET — Fetching data

Inject HttpClient with inject() and call .get<T>() with the expected response type as a generic. Call .subscribe() to start the request — the Observable is cold and does nothing until subscribed.

import { Component, inject, signal, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common/http';

interface Task {
  id: number;
  title: string;
}

@Component({ ... })
export class TasksComponent implements OnInit {
  private http = inject(HttpClient);

  tasks = signal<Task[]>([]);

  ngOnInit() {
    this.http.get<Task[]>('/api/tasks').subscribe({
      next: (data) => this.tasks.set(data),
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Loading and error state

complete is called after a successful response, but is not called when the request errors — so resetting isLoading in complete leaves it stuck on true after failures. Use finalize() instead: it runs after both completion and error.

import { finalize } from 'rxjs';

@Component({ ... })
export class TasksComponent implements OnInit {
  private http = inject(HttpClient);

  tasks     = signal<Task[]>([]);
  isLoading = signal(false);
  error     = signal('');

  ngOnInit() {
    this.isLoading.set(true);

    this.http.get<Task[]>('/api/tasks').pipe(
      finalize(() => this.isLoading.set(false))
    ).subscribe({
      next:  (data) => this.tasks.set(data),
      error: (err)  => this.error.set(err.message),
    });
  }
}
Enter fullscreen mode Exit fullscreen mode
@if (isLoading()) {
  <p>Loading…</p>
} @else if (error()) {
  <p class="error">{{ error() }}</p>
} @else {
  @for (task of tasks(); track task.id) {
    <li>{{ task.title }}</li>
  }
}
Enter fullscreen mode Exit fullscreen mode

3. Transforming the response with pipe()

Use map() to reshape the response before it reaches subscribe(). This is useful when the API wraps its data in a wrapper object.

import { map, finalize } from 'rxjs';

this.http.get<{ tasks: Task[]; total: number }>('/api/tasks').pipe(
  map(response => response.tasks),
  finalize(() => this.isLoading.set(false))
).subscribe({
  next:  (tasks) => this.tasks.set(tasks),
  error: (err)   => this.error.set(err.message),
});
Enter fullscreen mode Exit fullscreen mode

4. Custom error handling with catchError()

catchError() intercepts an error in the pipeline before it reaches subscribe. Use it to add context to the message or return a safe fallback value.

Re-throw with a custom message:

import { map, catchError, finalize } from 'rxjs';
import { throwError } from 'rxjs';

this.http.get<{ tasks: Task[] }>('/api/tasks').pipe(
  map(response => response.tasks),
  catchError(err =>
    throwError(() => new Error('Could not load tasks: ' + err.message))
  ),
  finalize(() => this.isLoading.set(false))
).subscribe({
  next:  (tasks) => this.tasks.set(tasks),
  error: (err)   => this.error.set(err.message), // receives the remapped error
});
Enter fullscreen mode Exit fullscreen mode

throwError(() => new Error(...)) — the factory function form is required in RxJS 7+. The old throwError('string') form is deprecated.

Return a fallback instead of re-throwing — lets the stream complete normally with a safe default:

import { catchError, of } from 'rxjs';

this.http.get<Task[]>('/api/tasks').pipe(
  catchError(() => of([]))  // on error, emit empty array and complete
).subscribe({
  next: (tasks) => this.tasks.set(tasks),
});
Enter fullscreen mode Exit fullscreen mode

5. Sending data — POST, PUT, PATCH, DELETE

Pass the request body as the second argument. Angular serializes it to JSON and sets Content-Type: application/json automatically.

POST — create a resource

addTask(title: string) {
  this.http.post<Task>('/api/tasks', { title }).subscribe({
    next:  (created) => this.tasks.update(list => [...list, created]),
    error: (err)     => this.error.set(err.message),
  });
}
Enter fullscreen mode Exit fullscreen mode

PUT — replace a resource

updateTask(task: Task) {
  this.http.put<Task>(`/api/tasks/${task.id}`, task).subscribe({
    next:  (updated) => this.tasks.update(list =>
      list.map(t => t.id === updated.id ? updated : t)
    ),
    error: (err) => this.error.set(err.message),
  });
}
Enter fullscreen mode Exit fullscreen mode

DELETE — remove a resource

deleteTask(id: number) {
  this.http.delete(`/api/tasks/${id}`).subscribe({
    next:  () => this.tasks.update(list => list.filter(t => t.id !== id)),
    error: (err) => this.error.set(err.message),
  });
}
Enter fullscreen mode Exit fullscreen mode

put() replaces the entire resource. Use patch() with the same signature to send only the changed fields.


6. HTTP in a service

Moving HTTP calls into a service separates data-fetching from the component and makes it reusable. The service returns the Observable; the component subscribes and handles the response.

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

@Injectable({ providedIn: 'root' })
export class TasksService {
  private http = inject(HttpClient);

  getTasks(): Observable<Task[]> {
    return this.http.get<Task[]>('/api/tasks');
  }

  addTask(title: string): Observable<Task> {
    return this.http.post<Task>('/api/tasks', { title });
  }
}
Enter fullscreen mode Exit fullscreen mode

In the component, subscribe and use takeUntilDestroyed to cancel the in-flight request if the component is destroyed before the response arrives:

import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { DestroyRef, inject } from '@angular/core';

@Component({ ... })
export class TasksComponent implements OnInit {
  private tasksService = inject(TasksService);
  private destroyRef   = inject(DestroyRef);

  tasks = signal<Task[]>([]);

  ngOnInit() {
    this.tasksService.getTasks()
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe({
        next:  (data) => this.tasks.set(data),
        error: (err)  => console.error(err),
      });
  }
}
Enter fullscreen mode Exit fullscreen mode

HTTP observables complete after one emission, so takeUntilDestroyed mainly guards against the component being destroyed mid-request — preventing signal updates on a destroyed view.


7. Shared state with signals in a service

When multiple components need the same data, hold the state in a signal inside the service. The service performs the HTTP call and updates the signal via tap(). Components read the signal directly — no need to pass data through @Input()/@Output().

// tasks.service.ts
import { Injectable, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { tap, finalize, catchError } from 'rxjs';
import { throwError } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class TasksService {
  private http = inject(HttpClient);

  // shared reactive state
  tasks     = signal<Task[]>([]);
  isLoading = signal(false);
  error     = signal('');

  loadTasks() {
    this.isLoading.set(true);
    this.error.set('');

    return this.http.get<Task[]>('/api/tasks').pipe(
      tap(tasks => this.tasks.set(tasks)),
      catchError(err => {
        this.error.set(err.message);
        return throwError(() => err);
      }),
      finalize(() => this.isLoading.set(false))
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

The component triggers the load and reads signals directly from the service. No next handler is needed — tap() already updated the signal.

// tasks-list.component.ts
@Component({ ... })
export class TasksListComponent implements OnInit {
  private tasksService = inject(TasksService);

  tasks     = this.tasksService.tasks;
  isLoading = this.tasksService.isLoading;
  error     = this.tasksService.error;

  ngOnInit() {
    this.tasksService.loadTasks().subscribe({
      error: () => {}, // error is already set on the service signal
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Any other component can read the same signal without triggering another request:

// tasks-summary.component.ts
@Component({ ... })
export class TasksSummaryComponent {
  private tasksService = inject(TasksService);

  taskCount = computed(() => this.tasksService.tasks().length);
}
Enter fullscreen mode Exit fullscreen mode

Because tasks is a signal on the singleton service, all components that read it update automatically when loadTasks() completes.


8. App-wide error handling with a shared error service

A common pattern in Angular apps is a singleton ErrorService that holds the current error as a signal. Any HTTP service that encounters an error calls errorService.show(). A single modal component in AppComponent reads that signal and displays the message — one place handles all errors, regardless of which service triggered them.

Why this is a best practice: it decouples error display from the services that produce errors, avoids duplicating modal/toast logic in every component, and works naturally with signals since the modal reacts to changes automatically.

// error.service.ts
import { Injectable, signal } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class ErrorService {
  private _message = signal<string | null>(null);
  readonly message = this._message.asReadonly(); // expose read-only to outside

  show(message: string) {
    this._message.set(message);
  }

  clear() {
    this._message.set(null);
  }
}
Enter fullscreen mode Exit fullscreen mode

asReadonly() prevents other classes from calling .set() directly — only the service's own methods can change the value.

The modal component reads the signal and shows itself when it has a value:

// error-modal.component.ts
import { Component, inject } from '@angular/core';
import { ErrorService } from './error.service';

@Component({
  selector: 'app-error-modal',
  standalone: true,
  template: `
    @if (errorService.message(); as message) {
      <div class="modal-overlay">
        <div class="modal">
          <p>{{ message }}</p>
          <button (click)="errorService.clear()">Dismiss</button>
        </div>
      </div>
    }
  `,
})
export class ErrorModalComponent {
  errorService = inject(ErrorService);
}
Enter fullscreen mode Exit fullscreen mode

Place it once in AppComponent so it is always present regardless of which route is active:

// app.component.ts
@Component({
  selector: 'app-root',
  standalone: true,
  imports: [RouterOutlet, ErrorModalComponent],
  template: `
    <router-outlet />
    <app-error-modal />
  `,
})
export class AppComponent {}
Enter fullscreen mode Exit fullscreen mode

HTTP services inject ErrorService and call show() in catchError. Return EMPTY instead of re-throwing — the error has been handled centrally so subscribers don't need an error handler:

// tasks.service.ts
import { Injectable, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { tap, catchError, finalize } from 'rxjs';
import { EMPTY } from 'rxjs';
import { ErrorService } from './error.service';

@Injectable({ providedIn: 'root' })
export class TasksService {
  private http         = inject(HttpClient);
  private errorService = inject(ErrorService);

  tasks     = signal<Task[]>([]);
  isLoading = signal(false);

  loadTasks() {
    this.isLoading.set(true);
    return this.http.get<Task[]>('/api/tasks').pipe(
      tap(tasks => this.tasks.set(tasks)),
      catchError(err => {
        this.errorService.show('Failed to load tasks: ' + err.message);
        return EMPTY; // swallow — error is handled by the error service
      }),
      finalize(() => this.isLoading.set(false))
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

The component subscribes with no error handler — the service and modal take care of it:

ngOnInit() {
  this.tasksService.loadTasks().subscribe();
}
Enter fullscreen mode Exit fullscreen mode

9. HTTP Interceptors

An interceptor sits between your code and the HTTP transport layer. Every request and response passes through it, making it the right place for cross-cutting concerns — auth headers, logging, global error handling — that would otherwise be duplicated across every service.

Functional interceptors (Angular 15+)

The modern form is a plain function typed as HttpInterceptorFn. It receives the outgoing request and a next function, and returns an Observable of the response.

// auth.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthService } from './auth.service';

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const token = inject(AuthService).token();
  if (!token) return next(req);

  const authReq = req.clone({
    setHeaders: { Authorization: `Bearer ${token}` }
  });
  return next(authReq);
};
Enter fullscreen mode Exit fullscreen mode

req.clone() is required because HttpRequest objects are immutable — you cannot modify a request in place.

Register interceptors in app.config.ts with withInterceptors(). They run in the order listed — first registered runs first on the way out and last on the way back in.

// app.config.ts
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { authInterceptor } from './auth.interceptor';
import { errorInterceptor } from './error.interceptor';

export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(withInterceptors([authInterceptor, errorInterceptor]))
  ]
};
Enter fullscreen mode Exit fullscreen mode

Intercepting the response

The response is available on the Observable returned by next(req). Pipe onto it to transform data or handle errors on the way back.

// error.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, throwError } from 'rxjs';
import { ErrorService } from './error.service';

export const errorInterceptor: HttpInterceptorFn = (req, next) => {
  const errorService = inject(ErrorService);
  return next(req).pipe(
    catchError(err => {
      errorService.show(err.message);
      return throwError(() => err);
    })
  );
};
Enter fullscreen mode Exit fullscreen mode

With this interceptor registered, no individual service needs to handle HTTP errors — they are caught centrally and displayed by the error modal from section 8.

Class-based interceptors (pre-Angular 15)

Before Angular 15, interceptors were classes implementing HttpInterceptor:

import { Injectable } from '@angular/core';
import { HttpInterceptor, HttpRequest, HttpHandler } from '@angular/common/http';

@Injectable()
export class AuthInterceptor implements HttpInterceptor {
  intercept(req: HttpRequest<unknown>, next: HttpHandler) {
    const authReq = req.clone({
      setHeaders: { Authorization: `Bearer ${this.getToken()}` }
    });
    return next.handle(authReq); // next.handle() instead of next()
  }
}
Enter fullscreen mode Exit fullscreen mode

Registered in the module providers array:

{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }
Enter fullscreen mode Exit fullscreen mode

For standalone apps that still use class interceptors, pass withInterceptorsFromDi() to provideHttpClient():

provideHttpClient(withInterceptorsFromDi())
Enter fullscreen mode Exit fullscreen mode

Prefer functional interceptors in modern code — they are easier to test (plain functions), tree-shakable, and use inject() without a constructor.

Common use cases

Use case Intercepts Technique
Add auth token Request req.clone({ setHeaders: { Authorization: ... } })
Prefix a base URL Request req.clone({ url: baseUrl + req.url })
Global error handling Response pipe(catchError(...))
Retry on server error Response pipe(retry({ count: 2, delay: 1000 }))
Log requests and responses Both tap() on the response Observable, log req in closure
Track in-flight requests Both Increment counter on request, decrement in finalize()

10. Modern pattern: toSignal()

For simple one-off fetches with no manual loading/error state, toSignal() converts the Observable directly to a signal and manages the subscription automatically.

import { toSignal } from '@angular/core/rxjs-interop';

@Component({ ... })
export class TasksComponent {
  private http = inject(HttpClient);

  tasks = toSignal(
    this.http.get<Task[]>('/api/tasks'),
    { initialValue: [] }
  );
}
Enter fullscreen mode Exit fullscreen mode
@for (task of tasks(); track task.id) {
  <li>{{ task.title }}</li>
}
Enter fullscreen mode Exit fullscreen mode

For reactive GET patterns like debounced search, combine toObservable() and toSignal():

query = signal('');

results = toSignal(
  toObservable(this.query).pipe(
    debounceTime(300),
    switchMap(q => this.http.get<Task[]>(`/api/tasks?search=${q}`))
  ),
  { initialValue: [] }
);
Enter fullscreen mode Exit fullscreen mode

switchMap cancels the in-flight request when query changes before the response arrives, preventing stale results.


Summary

Use case Approach
Simple fetch http.get<T>().subscribe({ next, error })
Loading state that resets on error too pipe(finalize(() => isLoading.set(false)))
Remap error messages pipe(catchError(err => throwError(() => new Error(...))))
Safe fallback on error pipe(catchError(() => of(defaultValue)))
Reusable HTTP logic Move to a service, return Observable, subscribe in component
Shared state across components Signal in service + tap() to update it
Simple fetch, no manual state toSignal(http.get(...), { initialValue: [] })
Reactive fetch from a signal input toObservable(signal).pipe(debounceTime, switchMap) → toSignal

Top comments (0)