HttpClient reste la base de la communication avec le serveur. Ce qui a changé : depuis la v21 il est disponible sans configuration, depuis la v22 il utilise l’API Fetch par défaut, les interceptors sont des fonctions, et pour les lectures réactives vous avez httpResource. Toutes les démos HTTP de ce site tournent sur un backend simulé par un interceptor (core/api/mock-backend.ts).
- v21 : les services HTTP sont fournis dans root —
inject(HttpClient)fonctionne sansprovideHttpClient(). - v22 :
FetchBackendest la valeur par défaut ;withFetch()est déprécié ; pour la progression d’un upload :withXhr(). - v22:
reportProgress→reportUploadProgress/reportDownloadProgress. - v22 :
httpResourceest stable ; TransferCache (SSR) n’inclut plus par défaut les requêtes avec credentials.
Configuration
// v21 : HttpClient est disponible dans root sans aucune configuration
export class UsersApi {
private readonly http = inject(HttpClient); // fonctionne directement
}
// vous n’appelez provideHttpClient() que pour le CONFIGURER
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(
withInterceptors([authInterceptor, errorInterceptor]),
// withXhr(), // v22 : Fetch est la valeur par défaut ; XHR seulement si vous avez besoin de la progression d’un upload
),
],
};Interceptors fonctionnels
// Interceptor fonctionnel : une fonction (req, next) => Observable
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const token = inject(AuthStore).token();
if (!token || !req.url.startsWith('/api')) return next(req);
return next(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }));
};
export const loadingInterceptor: HttpInterceptorFn = (req, next) => {
if (req.context.get(SKIP_LOADING)) return next(req); // métadonnées par requête
const loading = inject(LoadingStore);
loading.start();
return next(req).pipe(finalize(() => loading.stop()));
};
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
// inject() uniquement ici, de façon synchrone : dans catchError on n’est plus dans un contexte d’injection
const router = inject(Router);
return next(req).pipe(
catchError((err: HttpErrorResponse) => {
if (err.status === 401) router.navigate(['/login']);
return throwError(() => err);
}),
);
};Votre composanthttp.get('/api/users')
- Appuyez sur « Envoyer » : chaque ligne est écrite par le code de l’intercepteur, au moment où il s’exécute.
provideHttpClient(
withInterceptors([
authInterceptor,
loggingInterceptor,
retryInterceptor,
cacheInterceptor,
fakeServer, // seulement dans la démo : le serveur simulé
]),
)Les intercepteurs retry et cache du labo, sans les lignes de journal :
// Ne réessaie que les erreurs serveur (5xx), deux fois, avec une pause
export const retryInterceptor: HttpInterceptorFn = (req, next) => {
if (req.method !== 'GET') return next(req); // ne jamais répéter un POST automatiquement
return next(req).pipe(
retry({
count: 2,
delay: (error: HttpErrorResponse) =>
error.status >= 500 ? timer(300) : throwError(() => error),
}),
);
};
// dans un service, pas une variable de module : côté serveur, elle serait partagée par tous
@Service()
export class ResponseCache {
private readonly entries = new Map<string, HttpResponse<unknown>>();
get(key: string) {
return this.entries.get(key);
}
set(key: string, response: HttpResponse<unknown>) {
this.entries.set(key, response);
}
}
// Garde les réponses GET ; sur un hit, le reste de la chaîne ne s’exécute pas du tout
export const cacheInterceptor: HttpInterceptorFn = (req, next) => {
if (req.method !== 'GET') return next(req);
const cache = inject(ResponseCache);
const hit = cache.get(req.urlWithParams);
if (hit) return of(hit.clone());
return next(req).pipe(
tap((event) => {
if (event instanceof HttpResponse) cache.set(req.urlWithParams, event);
}),
);
};La couche d’API
Les composants ne devraient connaître ni les URL ni la forme des DTO. Un service par ressource les masque et renvoie des modèles du domaine (voir Interfaces et modèles).
// data-access/products-api.ts — URL, DTO et mapping au même endroit
@Service()
export class ProductsApi {
private readonly http = inject(HttpClient);
list(q = ''): Observable<Product[]> {
return this.http
.get<PageDto<ProductDto>>('/api/products', { params: { q } })
.pipe(map((page) => page.items.map(toProduct)));
}
get(id: number): Observable<Product> {
return this.http.get<ProductDto>(`/api/products/${id}`).pipe(
map(toProduct),
retry({ count: 2, delay: (_, attempt) => timer(300 * attempt) }), // backoff
);
}
create(body: CreateProductRequest): Observable<Product> {
return this.http.post<ProductDto>('/api/products', body).pipe(map(toProduct));
}
}- Faites une requête.
Lecture vs écriture
// LECTURE réactive → httpResource (dans un composant ou un store)
readonly product = httpResource(() => `/api/products/${this.id()}`, { parse: toProduct });
// ÉCRITURE → HttpClient (une commande, pas un état)
async save() {
const saved = await firstValueFrom(this.api.create(this.form().value()));
this.router.navigate(['/products', saved.id]);
}Changements de la v22
// v22 : FetchBackend est la valeur par défaut (withFetch() est déprécié — vous pouvez le retirer)
provideHttpClient(); // utilise fetch()
// Progression d’upload ? Fetch ne la gère pas → XHR explicite
provideHttpClient(withXhr());
this.http.post('/api/upload', file, { reportUploadProgress: true, observe: 'events' });
// reportProgress est déprécié → reportUploadProgress / reportDownloadProgress
// v22.1 : JSONP est déprécié (HttpClient.jsonp, HttpClientJsonpModule)Pièges fréquents
- Requêtes « cold » : un
http.get()sanssubscribe(oufirstValueFrom) n’est jamais envoyé. - Subscribe dans subscribe pour des requêtes dépendantes → utilisez
switchMapou des resources chaînées (ctx.chain). - Upload avec progression après la mise à niveau vers la v22 : Fetch ne signale pas la progression d’un upload →
withXhr(). - Des interceptors dans le mauvais ordre : ils s’appliquent dans l’ordre du tableau (auth avant les logs, etc.).
- Des erreurs gérées deux fois : l’interceptor affiche un toast, et le composant en affiche un autre. Décidez d’un seul endroit pour chaque type d’erreur.