HttpClient
Requêtes, intercepteurs fonctionnels, gestion d'erreur.
Vérifié en septembre 2026 · Angular 22.1 · environ 15 min
HttpClient est le client HTTP d'Angular. Il envoie une requête, rend la réponse sous forme d'observable typé, et fait passer chaque échange par une chaîne d'intercepteurs où se règlent, une fois pour toute l'application, l'authentification, la journalisation et le traitement des erreurs. Il sert dès qu'une application parle à une API, ASP.NET Core ou autre. Le cours suppose connus les cours RxJS et Signals : un appel à HttpClient est un observable froid, et httpResource, qui termine le cours, est le resource des signals branché sur HttpClient. Les exemples visent Angular 22.1, dans la version 22.1.7 de @angular/common qu'installe ce projet ; le site lui-même, fait pour s'ouvrir depuis le disque, n'envoie aucune requête.
Configurer HttpClient
Depuis Angular 21, HttpClient est fourni à la racine : un service peut l'injecter sans que l'application ait rien déclaré. Angular 22 a changé son moteur : les requêtes partent par fetch, et non plus par XMLHttpRequest. withFetch(), qu'il fallait ajouter en 21 pour obtenir fetch, est déprécié en 22, puisqu'il ne fait plus que ce que fait le réglage par défaut. provideHttpClient reste l'endroit où l'on modifie ce réglage, en lui passant des fonctionnalités.
// app.config.ts
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import type { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { authInterceptor } from './http/auth.interceptor';
import { journalInterceptor } from './http/journal.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
// Sans cet appel, HttpClient s'injecte quand meme depuis Angular 21, avec
// la protection XSRF, et avec fetch depuis Angular 22. provideHttpClient
// sert a changer ce reglage : ici, deux intercepteurs, dans cet ordre.
provideHttpClient(withInterceptors([journalInterceptor, authInterceptor])),
],
};| Fonctionnalité | Effet |
|---|---|
withInterceptors([...]) | ajoute des intercepteurs fonctionnels, exécutés dans l'ordre du tableau |
withXhr() | revient à XMLHttpRequest, seul à rapporter la progression d'un envoi |
withXsrfConfiguration(...), withNoXsrfProtection() | renomme le cookie et l'en-tête de la protection XSRF, ou la coupe |
withInterceptorsFromDi() | accepte les anciens intercepteurs de classe, déclarés par HTTP_INTERCEPTORS |
withRequestsMadeViaParent() | fait remonter les requêtes d'un injecteur enfant vers l'HttpClient parent |
withFetch() | déprécié en 22 : fetch est déjà le moteur par défaut |
La protection XSRF est active sans rien demander. Pour une requête qui n'est ni GET ni HEAD et vise l'origine de la page, un intercepteur interne copie le cookie XSRF-TOKEN, s'il existe, dans l'en-tête X-XSRF-TOKEN ; elle ne protège que si le serveur pose ce cookie et vérifie l'en-tête. Un détail de configuration surprend : un second provideHttpClient, placé dans les providers d'une route chargée en différé, crée une instance indépendante. Les intercepteurs de la racine ne s'appliquent plus à ses requêtes, sauf si cette instance déclare withRequestsMadeViaParent().
Des requêtes typées
Chaque méthode — get, post, put, patch, delete — rend un observable froid. Rien ne part tant qu'on ne s'y abonne pas, et chaque abonnement envoie sa propre requête : un post dont on oublie le subscribe n'enregistre rien, sans la moindre erreur. Se désabonner avant la réponse annule la requête, ce que switchMap exploite pour une recherche (cours RxJS). L'observable émet une seule valeur puis se termine : une fois la réponse arrivée, il n'y a pas d'abonnement à fermer.
// catalogue.ts
import { HttpClient, type HttpResponse } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { map, type Observable } from 'rxjs';
export interface Produit {
readonly reference: string;
readonly libelle: string;
readonly prix: number;
}
export interface Page<T> {
readonly elements: readonly T[];
readonly total: number;
}
@Injectable({ providedIn: 'root' })
export class Catalogue {
private readonly http = inject(HttpClient);
// Le parametre de type est une promesse faite au compilateur, pas une
// verification : rien ne controle que le JSON recu a cette forme.
produit(reference: string): Observable<Produit> {
return this.http.get<Produit>(`/api/produits/${encodeURIComponent(reference)}`);
}
// La forme objet de params convertit nombres et booleens ; un tableau repete
// la cle. Les valeurs sont encodees, jamais a concatener dans l'URL.
rechercher(terme: string, page: number, marques: readonly string[]): Observable<Produit[]> {
return this.http.get<Produit[]>('/api/produits', {
params: { q: terme, page, marque: marques },
headers: { 'Accept-Language': 'fr-FR' },
});
}
// rechercher('clavier sans fil', 2, ['logi', 'cherry']) envoie :
// GET /api/produits?q=clavier%20sans%20fil&page=2&marque=logi&marque=cherry
// observe: 'response' rend la reponse entiere, statut et en-tetes compris,
// au lieu du seul corps.
page(numero: number): Observable<Page<Produit>> {
return this.http
.get<Produit[]>('/api/produits', { params: { page: numero }, observe: 'response' })
.pipe(
map((reponse: HttpResponse<Produit[]>) => ({
elements: reponse.body ?? [],
total: Number(reponse.headers.get('X-Total-Count') ?? 0),
})),
);
}
// responseType change ce qui est lu : du texte brut, et le type rendu est
// Observable<string>. Il n'accepte pas de parametre de type.
exporterCsv(): Observable<string> {
return this.http.get('/api/produits/export', { responseType: 'text' });
}
// Le corps est serialise en JSON, avec Content-Type: application/json.
creer(produit: Produit): Observable<Produit> {
return this.http.post<Produit>('/api/produits', produit);
}
} Le paramètre de type de get<Produit> dit au compilateur ce qu'on attend, rien de plus. Si l'API renvoie autre chose, l'erreur se manifeste plus loin, sur une propriété undefined. Les params s'écrivent en objet, et HttpClient les encode : l'espace devient %20, et un tableau répète la clé, forme que la liaison d'ASP.NET Core lit comme une collection. observe: 'response' donne accès au statut et aux en-têtes, où une API range souvent le total d'une pagination ; observe: 'events' livre les événements un à un : l'envoi, puis la réponse ; reportProgress: true y ajoute les en-têtes reçus et la progression du téléchargement. responseType choisit la lecture du corps — 'json' par défaut, 'text', 'blob' ou 'arraybuffer' — et le type rendu suit.
HttpParams et HttpHeaders sont immuables, comme la requête elle-même : set, append et delete rendent une nouvelle instance. Traiter set comme une modification sur place perd le paramètre sans bruit.
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import type { Observable } from 'rxjs';
import type { Produit } from './catalogue';
@Injectable({ providedIn: 'root' })
export class RechercheAvancee {
private readonly http = inject(HttpClient);
rechercher(terme: string, marque?: string): Observable<Produit[]> {
const params = new HttpParams().set('q', terme);
if (marque !== undefined) {
// set ne modifie pas params : il rend un nouvel HttpParams, aussitot perdu.
params.set('marque', marque);
}
return this.http.get<Produit[]>('/api/produits', { params });
}
}
// rechercher('clavier', 'logi') envoie GET /api/produits?q=clavier
// Le filtre sur la marque a disparu, sans erreur ni avertissement.import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import type { Observable } from 'rxjs';
import type { Produit } from './catalogue';
@Injectable({ providedIn: 'root' })
export class RechercheAvancee {
private readonly http = inject(HttpClient);
rechercher(terme: string, marque?: string): Observable<Produit[]> {
let params = new HttpParams().set('q', terme);
if (marque !== undefined) {
params = params.set('marque', marque); // on garde la copie rendue
}
return this.http.get<Produit[]>('/api/produits', { params });
}
}
// rechercher('clavier', 'logi') envoie GET /api/produits?q=clavier&marque=logi L'immuabilité a une raison : une requête relancée par retry, ou déjà vue par un intercepteur, reste celle qui a été construite. La forme objet de params évite le piège en entier ; HttpParams ne sert que lorsqu'on assemble les paramètres pas à pas.
Intercepteurs fonctionnels
Un intercepteur est une fonction de type HttpInterceptorFn. Il reçoit la requête et next, la suite de la chaîne, et rend l'observable des événements de la réponse. Ce qu'il fait avant d'appeler next agit sur la requête ; ce qu'il enchaîne dans le pipe de next agit sur la réponse. Les intercepteurs s'emboîtent donc comme les middlewares d'ASP.NET Core : le premier du tableau voit la requête en premier et la réponse en dernier.
import { HttpEventType, type HttpInterceptorFn } from '@angular/common/http';
import { tap } from 'rxjs';
function trace(nom: string): HttpInterceptorFn {
return (req, next) => {
console.log(`${nom} : aller`);
return next(req).pipe(
tap((evenement) => {
if (evenement.type === HttpEventType.Response) {
console.log(`${nom} : retour`);
}
}),
);
};
}
export const premier = trace('premier');
export const second = trace('second');
// provideHttpClient(withInterceptors([premier, second])), puis une requete :
// premier : aller
// second : aller <- puis le backend envoie la requete
// second : retour
// premier : retourUn journal se place en tête : il mesure la durée de toute la chaîne, et voit l'échec tel que l'appelant le recevra.
// http/journal.interceptor.ts
import { HttpEventType, type HttpInterceptorFn } from '@angular/common/http';
import { tap } from 'rxjs';
export const journalInterceptor: HttpInterceptorFn = (req, next) => {
const debut = performance.now();
// Avant next : la requete, a l'aller. Dans le pipe : la reponse, au retour.
return next(req).pipe(
tap({
next: (evenement) => {
if (evenement.type === HttpEventType.Response) {
const duree = Math.round(performance.now() - debut);
console.log(`${req.method} ${req.urlWithParams} ${evenement.status} en ${duree} ms`);
}
},
error: () => console.log(`${req.method} ${req.urlWithParams} en echec`),
}),
);
}; L'intercepteur d'authentification réunit les trois outils du genre. clone modifie une requête immuable. inject, appelé dans le corps de la fonction, lit un service, parce qu'Angular exécute chaque intercepteur dans le contexte d'injection de l'injecteur qui l'a déclaré. Un HttpContextToken porte une option par requête, lue par les intercepteurs et jamais envoyée au serveur. Le test sur l'URL n'est pas un détail : sans lui, le jeton part vers toute URL absolue appelée par HttpClient, API tierce comprise.
// http/session.ts
import { Injectable, signal } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class Session {
// null tant que personne n'est connecte.
readonly jeton = signal<string | null>(null);
}
// http/contexte.ts
import { HttpContextToken } from '@angular/common/http';
// La fabrique donne la valeur des requetes qui ne disent rien : l'intercepteur
// s'applique par defaut, et une requete s'en dispense en le disant.
export const SANS_AUTHENTIFICATION = new HttpContextToken<boolean>(() => false);
// http/auth.interceptor.ts
import type { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { SANS_AUTHENTIFICATION } from './contexte';
import { Session } from './session';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
// Le corps de l'intercepteur s'execute dans un contexte d'injection.
const jeton = inject(Session).jeton();
// Le jeton ne part que vers notre API : une URL absolue vers un tiers
// (une carte, un CDN) ne doit jamais le recevoir.
const versNotreApi = req.url.startsWith('/api/');
if (jeton === null || !versNotreApi || req.context.get(SANS_AUTHENTIFICATION)) {
return next(req);
}
// HttpRequest est immuable : clone rend une copie, avec l'en-tete en plus.
return next(req.clone({ setHeaders: { Authorization: `Bearer ${jeton}` } }));
};La requête de connexion est celle qui s'en dispense :
// connexion.ts
import { HttpClient, HttpContext } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { tap, type Observable } from 'rxjs';
import { SANS_AUTHENTIFICATION } from './http/contexte';
import { Session } from './http/session';
interface Identifiants {
readonly courriel: string;
readonly motDePasse: string;
}
@Injectable({ providedIn: 'root' })
export class Connexion {
private readonly http = inject(HttpClient);
private readonly session = inject(Session);
// Se connecter avec un jeton perime ne sert a rien, et certaines API le
// refusent : cette requete-la se dispense de l'intercepteur.
seConnecter(identifiants: Identifiants): Observable<{ jeton: string }> {
return this.http
.post<{ jeton: string }>('/api/connexion', identifiants, {
context: new HttpContext().set(SANS_AUTHENTIFICATION, true),
})
.pipe(tap(({ jeton }) => this.session.jeton.set(jeton)));
}
}La valeur par défaut vient de la fabrique du jeton : une requête sans contexte laisse donc l'intercepteur agir. Le contexte, contrairement à la requête, est modifiable, et c'est voulu : un intercepteur peut y noter un état qu'il retrouvera si la requête est relancée.
Le contexte d'injection ne dure que le temps de l'appel de l'intercepteur. Un inject écrit dans un rappel du pipe s'exécute à l'arrivée de la réponse, hors de ce contexte, et lève NG0203 à la place de l'erreur qu'il devait traiter.
import { HttpErrorResponse, type HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { catchError, throwError } from 'rxjs';
export const sessionExpireeInterceptor: HttpInterceptorFn = (req, next) =>
next(req).pipe(
catchError((erreur: unknown) => {
if (erreur instanceof HttpErrorResponse && erreur.status === 401) {
// Ce rappel s'execute a l'arrivee de la reponse, bien apres le retour
// de l'intercepteur : le contexte d'injection n'existe plus.
void inject(Router).navigate(['/connexion']);
}
return throwError(() => erreur);
}),
);
// A la premiere reponse 401, l'appelant ne recoit pas l'HttpErrorResponse
// mais l'erreur levee dans le rappel (mode developpement, Angular 22.1) :
// NG0203: The `Router` token injection failed. `inject()` function must be
// called from an injection context such as a constructor, a factory function,
// a field initializer, or a function used with `runInInjectionContext`. Find
// more at https://v22.angular.dev/errors/NG0203import { HttpErrorResponse, type HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { catchError, throwError } from 'rxjs';
export const sessionExpireeInterceptor: HttpInterceptorFn = (req, next) => {
// Injecte pendant l'appel de l'intercepteur, donc dans le contexte ;
// le rappel ne fait que lire la constante qu'il capture.
const router = inject(Router);
return next(req).pipe(
catchError((erreur: unknown) => {
if (erreur instanceof HttpErrorResponse && erreur.status === 401) {
void router.navigate(['/connexion']);
}
// L'erreur continue vers l'appelant, qui peut encore la traiter.
return throwError(() => erreur);
}),
);
}; Le défaut reste invisible tant qu'aucun 401 n'arrive, puisque le rappel ne s'exécute qu'à ce moment-là. Les intercepteurs de classe, qui implémentent HttpInterceptor, font le même travail ; la documentation d'Angular recommande les fonctionnels, dont l'ordre est celui du tableau, et non celui, difficile à prévoir dans une application découpée, de l'enregistrement des providers.
Gérer les erreurs
Tout échec arrive par le canal error de l'observable, sous la forme d'une HttpErrorResponse : status, statusText, headers, url et error. Le statut sépare deux cas. Un statut HTTP, 404 ou 500, veut dire que le serveur a répondu : error contient le corps, déjà lu comme JSON, typiquement le ProblemDetails qu'une API ASP.NET Core bien tenue renvoie (cours Qualité d'API). Un statut 0 veut dire qu'aucune réponse n'est lisible : réseau coupé, serveur injoignable, délai de l'option timeout dépassé, ou refus CORS du navigateur. Ce dernier cas recouvre deux situations que le même cours distingue : si la requête était simple, le serveur l'a exécutée et seule sa réponse est cachée au script ; si c'est le pré-vol qui échoue, comme pour un POST JSON ou un en-tête Authorization, la requête réelle ne part pas. Les deux donnent le statut 0. error contient alors l'exception levée par fetch : un TypeError pour une connexion refusée, une DOMException nommée TimeoutError pour un délai dépassé.
Deux détails évitent des surprises. HttpErrorResponse n'hérite pas d'Error : instanceof Error est faux, et c'est instanceof HttpErrorResponse qu'il faut tester. Son message, de la forme « Http failure response for https://api.exemple.fr/api/produits/K-999: 404 Not Found », avec l'URL absolue de la réponse, est fait pour un journal, pas pour un écran. Le texte qui suit le code est le texte de statut du serveur. HTTP/2 ne le transmet plus : fetch le rend vide, le message s'arrête alors au code, et la propriété statusText, dépréciée en Angular 22 pour cette raison, prend une valeur par défaut ('Unknown Error' pour une erreur). Seul status est fiable.
// erreurs.ts
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { catchError, map, of, type Observable } from 'rxjs';
import type { Produit } from './catalogue';
export type Chargement<T> =
{ readonly ok: true; readonly valeur: T } | { readonly ok: false; readonly message: string };
// Ce qu'une API ASP.NET Core renvoie en cas d'erreur (RFC 9457).
interface ProblemDetails {
readonly title?: string;
readonly detail?: string;
}
export function messagePour(erreur: unknown): string {
if (!(erreur instanceof HttpErrorResponse)) {
throw erreur; // une erreur de programmation n'est pas une erreur HTTP
}
if (erreur.status === 0) {
// Aucune reponse lisible : reseau coupe, serveur injoignable, reponse
// bloquee par CORS, delai depasse. erreur.error est l'exception levee
// par fetch, pas un corps.
return 'Serveur injoignable, verifiez votre connexion.';
}
// Sinon, erreur.error est le corps de la reponse, deja lu comme JSON.
const probleme = erreur.error as ProblemDetails | null;
return probleme?.detail ?? probleme?.title ?? `Erreur ${erreur.status}`;
}
@Injectable({ providedIn: 'root' })
export class Fiches {
private readonly http = inject(HttpClient);
charger(reference: string): Observable<Chargement<Produit>> {
return this.http.get<Produit>(`/api/produits/${encodeURIComponent(reference)}`).pipe(
map((valeur) => ({ ok: true, valeur }) as const),
// L'observable d'HttpClient ne rend qu'une valeur : le remplacer par of()
// ne coupe aucun flux durable, contrairement au cas du cours RxJS.
catchError((erreur: unknown) => of({ ok: false, message: messagePour(erreur) } as const)),
);
}
}
// Reponse 404 avec { "title": "Not Found", "detail": "Aucun produit K-999." } :
// { ok: false, message: 'Aucun produit K-999.' }
// Serveur arrete :
// { ok: false, message: 'Serveur injoignable, verifiez votre connexion.' } Le catchError se place dans le service, sur l'observable de la requête. Il n'y coupe rien, puisque ce flux s'arrête de toute façon après une valeur. Le piège du cours RxJS, un catchError qui termine un flux durable, ne se pose que lorsque la requête est aplatie dans une chaîne plus longue, et la réponse y est la même : le catchError va sur le flux interne. Les erreurs communes à toute l'application, comme un 401 qui renvoie vers la connexion, vont dans un intercepteur ; le message propre à un écran reste dans son service.
retry réabonne l'observable après une erreur, donc renvoie la requête. retry(3) le fait pour toute erreur, aussitôt : il relance ce qui échouera de nouveau, et ce qui ne devait pas être relancé.
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { retry, type Observable } from 'rxjs';
import type { Produit } from './catalogue';
@Injectable({ providedIn: 'root' })
export class Stock {
private readonly http = inject(HttpClient);
produit(reference: string): Observable<Produit> {
// Un 404 part quatre fois de suite, sans attendre : le produit n'existera
// pas davantage a la quatrieme. Un 503 est relance aussitot, contre un
// serveur deja sature.
return this.http.get<Produit>(`/api/produits/${encodeURIComponent(reference)}`).pipe(retry(3));
}
commander(reference: string, quantite: number): Observable<void> {
// Le serveur a pu enregistrer la commande avant que la reponse se perde
// (statut 0) ou qu'une passerelle expire (504) : chaque relance est
// peut-etre une commande de plus.
return this.http.post<void>('/api/commandes', { reference, quantite }).pipe(retry(3));
}
}import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { retry, throwError, timer, type MonoTypeOperatorFunction, type Observable } from 'rxjs';
import type { Produit } from './catalogue';
// Relancer n'a de sens que si la meme requete peut reussir un peu plus tard.
// 501 (methode non implementee) et 505 (version HTTP refusee) ne changeront
// pas. Le statut 0 reste un pari : une coupure passe, un refus CORS non, et
// ses trois relances echoueront pareil.
const DEFINITIFS = new Set([501, 505]);
function passager(erreur: unknown): erreur is HttpErrorResponse {
if (!(erreur instanceof HttpErrorResponse)) {
return false;
}
const statut = erreur.status;
return (
statut === 0 || statut === 408 || statut === 429 || (statut >= 500 && !DEFINITIFS.has(statut))
);
}
export function relancer<T>(tentatives = 3): MonoTypeOperatorFunction<T> {
return retry({
count: tentatives,
delay: (erreur: unknown, essai: number) => {
if (!passager(erreur)) {
return throwError(() => erreur); // abandon : l'erreur d'origine passe telle quelle
}
// Retry-After (en secondes) si le serveur le donne, sinon 500, 1000, 2000 ms.
const imposee = Number(erreur.headers.get('Retry-After'));
return timer(imposee > 0 ? imposee * 1000 : 500 * 2 ** (essai - 1));
},
});
}
@Injectable({ providedIn: 'root' })
export class Stock {
private readonly http = inject(HttpClient);
// Une lecture est idempotente : la relancer ne change rien cote serveur.
produit(reference: string): Observable<Produit> {
return this.http
.get<Produit>(`/api/produits/${encodeURIComponent(reference)}`)
.pipe(relancer());
}
// Une ecriture ne se relance pas a l'aveugle : l'erreur remonte a l'ecran,
// qui dira a l'utilisateur de verifier ses commandes avant de reessayer.
commander(reference: string, quantite: number): Observable<void> {
return this.http.post<void>('/api/commandes', { reference, quantite });
}
}
// produit('K-1') contre 503, 503, puis 200 : requetes a 0, 500 et 1500 ms,
// puis la valeur. Quatre 503 : requetes a 0, 500, 1500 et 3500 ms, puis le 503.
// Un 429 avec Retry-After: 1 : seconde requete a 1000 ms.
// Un 404 : une seule requete, et le 404 tel quel. Une erreur 4xx dit que la requête elle-même est en cause : la renvoyer telle quelle donnera la même réponse. Seuls 408 et 429 font exception, parce qu'ils disent « pas maintenant » ; 429 arrive avec Retry-After quand le limiteur est configuré comme dans le cours Qualité d'API, et l'attente le respecte. Restent le statut 0 et les 5xx, le plus souvent passagers. Pas toujours : 501 et 505 décrivent ce que le serveur ne sait pas faire, et le code les écarte ; un refus CORS donne aussi le statut 0, et ses trois relances échoueront pareil, prix accepté d'une règle qui ne peut pas le distinguer d'une coupure réseau. L'attente double à chaque essai : un serveur en difficulté n'a pas besoin que tous ses clients reviennent à la même seconde. Une requête non idempotente, enfin, ne se relance pas à l'aveugle, même sur un 504 : la passerelle a expiré, pas forcément le traitement. Placé dans un intercepteur, le même retry s'appliquerait à toute l'application, écritures comprises, sauf à filtrer sur la méthode.
httpResource
httpResource, expérimental depuis Angular 19.2 et stable depuis 22.0, est le resource du cours Signals dont le chargeur serait HttpClient. Il prend une fonction qui rend l'URL, ou un objet requête, et la suit comme un computed : chaque changement d'un signal qu'elle lit relance la requête, en annulant celle qui était en cours. Le résultat s'expose en signals, value, status, error, isLoading et hasValue(), avec en plus statusCode, headers et progress. Parce qu'il passe par HttpClient, il traverse les mêmes intercepteurs : le journal de la section précédente trace aussi ses requêtes.
// fiche-produit.ts
import { HttpErrorResponse, httpResource } from '@angular/common/http';
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import type { Produit } from './catalogue';
@Component({
selector: 'app-fiche-produit',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (produit.isLoading()) {
<p>Chargement...</p>
} @else if (produit.error()) {
<p>{{ message() }}</p>
<button type="button" (click)="produit.reload()">Reessayer</button>
} @else if (produit.hasValue()) {
<h2>{{ produit.value().libelle }}</h2>
<p>{{ produit.value().prix }} EUR</p>
}
`,
})
export class FicheProduit {
readonly reference = input.required<string>();
// La fonction est suivie comme un computed : une nouvelle reference annule
// la requete en cours et en lance une autre. Rendre undefined laisserait la
// ressource au repos, sans requete.
protected readonly produit = httpResource<Produit>(
() => `/api/produits/${encodeURIComponent(this.reference())}`,
);
// error() est l'HttpErrorResponse de la requete : statut et corps compris.
protected readonly message = computed(() => {
const erreur = this.produit.error();
return erreur instanceof HttpErrorResponse && erreur.status === 404
? "Ce produit n'existe pas."
: 'Chargement impossible pour le moment.';
});
} Contrairement à un appel à HttpClient, httpResource n'attend pas d'abonnement : la requête part d'elle-même, au premier rendu du composant qui la déclare. Les statuts sont ceux de resource : 'loading' à chaque nouvelle requête, parce que l'URL ou ses paramètres ont changé, 'reloading' seulement après reload(), 'error' avec l'HttpErrorResponse dans error() et le code dans statusCode(). La règle du cours Signals vaut à l'identique : en 'error', value() lève une exception, et le template teste l'erreur avant de lire la valeur, ou garde la lecture derrière hasValue().
La forme objet reprend les options d'HttpClient, et rendre undefined laisse la ressource en 'idle', sans requête. L'option parse remplace la promesse du paramètre de type par une vérification : son type de retour devient celui de la valeur, et une exception qu'elle lève fait passer la ressource en erreur. Pendant un chargement, value() rend la valeur par défaut, ici un tableau vide : le template teste donc isLoading() en premier, sans quoi il annoncerait « Aucun résultat » avant même la réponse.
// recherche.ts
import { httpResource } from '@angular/common/http';
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import type { Produit } from './catalogue';
// Valide ce que le serveur renvoie au lieu de le croire sur parole.
function lireProduits(brut: unknown): Produit[] {
if (!Array.isArray(brut)) {
throw new Error('Reponse inattendue : un tableau etait attendu.');
}
return brut as Produit[];
}
@Component({
selector: 'app-recherche',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<input #champ type="search" [value]="terme()" (input)="terme.set(champ.value)" />
<!-- Pendant le chargement, value() vaut la valeur par defaut, [] : sans
ce premier cas, l'ecran annoncerait « Aucun resultat. » a tort. -->
@if (resultats.isLoading()) {
<p>Recherche en cours...</p>
} @else if (resultats.error()) {
<p>Recherche indisponible.</p>
} @else if (resultats.status() === 'idle') {
<p>Saisissez au moins deux lettres.</p>
} @else {
@for (produit of resultats.value(); track produit.reference) {
<p>{{ produit.libelle }}</p>
} @empty {
<p>Aucun resultat.</p>
}
}
`,
})
export class Recherche {
protected readonly terme = signal('');
// La forme requete : url, params, headers, comme les options d'HttpClient.
// undefined tant que le terme est trop court : statut 'idle', aucune requete.
protected readonly resultats = httpResource(
() => {
const terme = this.terme().trim();
return terme.length < 2 ? undefined : { url: '/api/produits', params: { q: terme } };
},
{ parse: lireProduits, defaultValue: [] },
);
}httpResource sert à lire. Pour une écriture, son annulation automatique serait un défaut, comme pour resource, et la documentation d'Angular le réserve aux lectures : un POST reste un appel à HttpClient, dans un gestionnaire d'événement. Pour une donnée qui dépend seulement de signals, il remplace le trajet toObservable, switchMap, toSignal du cours RxJS. Pour espacer les frappes, ce trajet et son debounceTime restent la voie stable. Angular 22.0 propose aussi debounced, dans @angular/core, encore expérimental : il rend une ressource dont la valeur suit un signal après un temps de calme, et la fonction de httpResource peut lire cette valeur à la place du signal brut.