Angular httpResource() : HTTP réactif sans RxJS

Front-end 31/03/2026 15:00:00 angularforall.com
Angular Httpresource Signals Http Rxjs
Angular httpResource() : HTTP réactif sans RxJS

httpResource() Angular 19.2+ : requêtes HTTP signal-based, Resource<T>, status, isLoading, reload() et remplacement du pattern HttpClient.

Pourquoi httpResource() ? Le problème avec HttpClient

Depuis Angular 2, la façon standard d'effectuer des requêtes HTTP est d'utiliser HttpClient combiné à RxJS. Cette approche fonctionne très bien pour des cas complexes (retry, debounce, switchMap...), mais pour le cas le plus courant — charger des données au montage d'un composant — elle est souvent surdimensionnée.

Voici le pattern classique que tout développeur Angular connaît :

// ❌ Ancien pattern — HttpClient + RxJS + subscribe manuel
import { Component, OnInit, OnDestroy } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Subscription } from 'rxjs';

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

@Component({
  selector: 'app-users',
  standalone: true,
  template: `
    <div *ngIf="isLoading">Chargement...</div>
    <div *ngIf="error">Erreur : {{ error }}</div>
    <ul *ngIf="!isLoading">
      <li *ngFor="let user of users">{{ user.name }}</li>
    </ul>
  `
})
export class UsersComponent implements OnInit, OnDestroy {
  users: User[] = [];
  isLoading = false;
  error: string | null = null;

  // Il faut stocker la subscription pour la désabonner manuellement
  private sub!: Subscription;

  constructor(private http: HttpClient) {}

  ngOnInit() {
    this.isLoading = true;

    // subscribe() crée une subscription manuelle à gérer
    this.sub = this.http.get<User[]>('/api/users').subscribe({
      next: (data) => {
        this.users = data;
        this.isLoading = false;
      },
      error: (err) => {
        this.error = err.message;
        this.isLoading = false;
      }
    });
  }

  ngOnDestroy() {
    // Oubliez ceci = memory leak garanti
    this.sub.unsubscribe();
  }
}

Ce code présente plusieurs problèmes :

  • Boilerplate excessif : 3 propriétés d'état (isLoading, error, users), une subscription à gérer, deux lifecycle hooks.
  • Risque de memory leak : oublier le unsubscribe() dans ngOnDestroy est une erreur fréquente chez les débutants.
  • Pas réactif aux signals : si un signal change (ex: l'ID de l'utilisateur sélectionné), il faut relancer la requête manuellement.
  • Pas de synchronisation avec la Change Detection : OnPush complique le tableau.
À retenir : httpResource() est une API Angular 21 (signal-based) qui remplace ce pattern verbose par une syntaxe déclarative en une ligne, avec gestion automatique du cycle de vie, des états et de la réactivité.

Angular a introduit les Signals en version 16, puis les a progressivement généralisés. httpResource() est la concrétisation de cette vision pour les requêtes HTTP : une API orientée données qui expose l'état de la requête via des signals lisibles directement dans le template.

Premiers pas avec httpResource() : syntaxe de base

httpResource() est importé depuis @angular/common/http et s'utilise directement dans un composant ou un service. Il prend en premier argument l'URL de la requête (ou une fonction qui retourne l'URL) et retourne un objet Resource<T>.

Installation et prérequis :

# Vérifier la version d'Angular (nécessite 19.2+ pour httpResource stable)
ng version

# Mettre à jour si nécessaire
ng update @angular/core @angular/cli

Exemple minimal : charger une liste d'utilisateurs

// users.component.ts — syntaxe minimale avec httpResource()
import { Component } from '@angular/core';
import { httpResource } from '@angular/common/http';

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

@Component({
  selector: 'app-users',
  standalone: true,
  template: `
    <!-- isLoading() est un signal : pas de subscribe, pas de async pipe -->
    @if (users.isLoading()) {
      <p>Chargement en cours...</p>
    }

    <!-- value() retourne les données ou undefined si pas encore chargé -->
    @if (users.value(); as data) {
      <ul>
        @for (user of data; track user.id) {
          <li>{{ user.name }} — {{ user.email }}</li>
        }
      </ul>
    }

    <!-- error() retourne l'erreur si la requête a échoué -->
    @if (users.error()) {
      <p class="text-danger">Erreur de chargement</p>
    }
  `
})
export class UsersComponent {
  // Une seule ligne remplace tout le boilerplate HttpClient + RxJS
  // httpResource() retourne un Resource<User[]>
  users = httpResource<User[]>('/api/users');
}
À retenir : Plus de ngOnInit, plus de subscribe, plus de ngOnDestroy. Angular gère automatiquement le cycle de vie de la requête. La requête est lancée dès l'instanciation du composant.

Configurer les options de la requête :

// Passer des options HTTP comme avec HttpClient
users = httpResource<User[]>({
  // URL de la requête
  url: '/api/users',

  // Méthode HTTP (GET par défaut)
  method: 'GET',

  // Headers personnalisés
  headers: {
    'Accept': 'application/json',
    'X-Custom-Header': 'my-value'
  },

  // Paramètres de query string (?page=1&limit=20)
  params: {
    page: '1',
    limit: '20'
  }
});

Utiliser httpResource() dans un service injecté :

// user.service.ts — encapsuler httpResource dans un service Angular
import { Injectable } from '@angular/core';
import { httpResource } from '@angular/common/http';

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

@Injectable({
  providedIn: 'root'
})
export class UserService {
  // Le Resource est créé une fois et partagé via le service
  // Tous les composants injectant ce service liront le même état
  readonly users = httpResource<User[]>('/api/users');
}
Note : httpResource() doit être appelé dans un contexte d'injection (constructeur, champ de classe, ou runInInjectionContext). Il ne peut pas être appelé depuis une fonction utilitaire classique sans contexte Angular.

L'objet Resource<T> : value, status, isLoading, error

La valeur retournée par httpResource() est un objet Resource<T> qui expose plusieurs signals en lecture seule. Chaque signal se lit en l'appelant comme une fonction : resource.value(), resource.isLoading(), etc.

Signal / Méthode Type retourné Description
value() T | undefined Les données reçues, ou undefined avant la première réponse
isLoading() boolean true pendant le chargement (requête en cours)
status() ResourceStatus État détaillé : Idle, Loading, Resolved, Error, Reloading, Local
error() unknown L'erreur levée lors d'un échec, ou undefined
reload() boolean Méthode (pas un signal) : force la relance de la requête
set(value) void Méthode : remplace localement la valeur sans requête HTTP (statut Local)
update(fn) void Méthode : modifie localement la valeur courante via une fonction de transformation

Lire chaque signal dans le template :

// product-detail.component.ts — utilisation complète des signals Resource<T>
import { Component, computed } from '@angular/core';
import { httpResource, ResourceStatus } from '@angular/common/http';

interface Product {
  id: number;
  name: string;
  price: number;
  stock: number;
}

@Component({
  selector: 'app-product-detail',
  standalone: true,
  template: `
    <!-- Afficher l'état détaillé via status() -->
    <p>État : {{ statusLabel() }}</p>

    <!-- isLoading() : indicateur simple de chargement -->
    @if (product.isLoading()) {
      <div class="spinner-border text-primary" role="status">
        <span class="visually-hidden">Chargement...</span>
      </div>
    }

    <!-- value() : données disponibles -->
    @if (product.value(); as p) {
      <h2>{{ p.name }}</h2>
      <p>Prix : {{ p.price | currency:'EUR' }}</p>
      <p>Stock : {{ p.stock }} unités</p>
    }

    <!-- error() : message d'erreur typé -->
    @if (product.error()) {
      <div class="alert alert-danger">
        Impossible de charger le produit.
      </div>
    }

    <!-- Bouton de rechargement manuel -->
    <button (click)="product.reload()" [disabled]="product.isLoading()">
      Actualiser
    </button>
  `
})
export class ProductDetailComponent {
  // Charger un produit depuis l'API
  product = httpResource<Product>('/api/products/42');

  // Computed signal : transformer le statut en libellé lisible
  statusLabel = computed(() => {
    switch (this.product.status()) {
      case ResourceStatus.Idle:      return 'En attente';
      case ResourceStatus.Loading:   return 'Chargement...';
      case ResourceStatus.Resolved:  return 'Chargé';
      case ResourceStatus.Error:     return 'Erreur';
      case ResourceStatus.Reloading: return 'Rechargement...';
      case ResourceStatus.Local:     return 'Modifié localement';
      default:                       return 'Inconnu';
    }
  });
}

Utiliser set() pour une mise à jour optimiste :

// Mise à jour optimiste : modifier la valeur locale avant confirmation serveur
// Utile pour une UX fluide (pas d'attente du rechargement HTTP)
updateStock(newStock: number): void {
  // Modifier immédiatement la valeur affichée (statut passe à "Local")
  this.product.update((current) => {
    // Si pas de données chargées, ne rien faire
    if (!current) return current;
    // Retourner une nouvelle version avec le stock mis à jour
    return { ...current, stock: newStock };
  });

  // Envoyer la mise à jour au serveur en parallèle
  this.http.patch('/api/products/42', { stock: newStock }).subscribe({
    error: () => {
      // En cas d'erreur serveur, recharger les données réelles
      this.product.reload();
    }
  });
}
Note : Quand set() ou update() sont appelés, le statut passe à ResourceStatus.Local. La valeur locale est affichée jusqu'au prochain reload() ou changement de paramètre.

Requêtes réactives avec des paramètres Signal

C'est là que httpResource() montre sa vraie puissance. En passant une fonction comme premier argument (au lieu d'une URL statique), la requête se relance automatiquement chaque fois qu'un signal lu dans cette fonction change. C'est la réactivité "signal-based" en action.

Exemple : filtrage de produits en temps réel

// product-list.component.ts — requêtes réactives avec signal()
import { Component, signal, computed } from '@angular/core';
import { httpResource } from '@angular/common/http';
import { FormsModule } from '@angular/forms';

interface Product {
  id: number;
  name: string;
  category: string;
  price: number;
}

@Component({
  selector: 'app-product-list',
  standalone: true,
  imports: [FormsModule],
  template: `
    <!-- Champ de recherche lié au signal -->
    <input
      type="text"
      [(ngModel)]="searchQuery"
      placeholder="Rechercher un produit..."
      class="form-control mb-3"
    />

    <!-- Sélecteur de catégorie -->
    <select [(ngModel)]="selectedCategory" class="form-select mb-3">
      <option value="">Toutes les catégories</option>
      <option value="electronics">Électronique</option>
      <option value="clothing">Vêtements</option>
    </select>

    @if (products.isLoading()) {
      <p>Recherche en cours...</p>
    }

    @for (product of products.value() ?? []; track product.id) {
      <div class="card mb-2">
        <div class="card-body">
          <h5>{{ product.name }}</h5>
          <p>{{ product.price }}€ — {{ product.category }}</p>
        </div>
      </div>
    }
  `
})
export class ProductListComponent {
  // Signals liés aux inputs utilisateur
  searchQuery    = signal('');
  selectedCategory = signal('');

  // httpResource avec une fonction : la requête se relance
  // automatiquement quand searchQuery() ou selectedCategory() changent
  products = httpResource<Product[]>(() => {
    // Lire les signals déclenche la réactivité automatiquement
    const query    = this.searchQuery();
    const category = this.selectedCategory();

    // Construire l'URL avec les paramètres dynamiques
    const params = new URLSearchParams();
    if (query)    params.set('q', query);
    if (category) params.set('category', category);

    // Retourner l'URL complète — Angular relancera la requête à chaque changement
    return `/api/products?${params.toString()}`;
  });
}
À retenir : La clé est de passer une fonction (arrow function) et non une chaîne statique. Angular trackera automatiquement les signals lus à l'intérieur et relancera la requête si l'un d'eux change.

Désactiver la requête conditionnellement avec undefined :

// Requête conditionnelle : ne se déclenche que si userId est défini
import { Component, input } from '@angular/core';
import { httpResource } from '@angular/common/http';

interface UserProfile {
  id: number;
  name: string;
  avatar: string;
}

@Component({
  selector: 'app-user-profile',
  standalone: true,
  template: `
    @if (profile.value(); as user) {
      <img [src]="user.avatar" [alt]="user.name" />
      <h3>{{ user.name }}</h3>
    }
  `
})
export class UserProfileComponent {
  // Input signal : reçu du composant parent
  userId = input<number | null>(null);

  // La requête ne se lance QUE si userId est non-null
  // Retourner undefined depuis la fonction suspend la requête
  profile = httpResource<UserProfile>(() => {
    const id = this.userId();
    // Si pas d'ID, retourner undefined = requête suspendue (statut Idle)
    if (!id) return undefined;
    // Sinon, construire l'URL avec l'ID
    return `/api/users/${id}`;
  });
}

Débounce des requêtes avec un signal calculé :

// Éviter de spammer l'API à chaque frappe clavier
// Utiliser un signal debounced (pattern custom)
import { Component, signal, computed, effect } from '@angular/core';
import { httpResource } from '@angular/common/http';

@Component({ selector: 'app-search', standalone: true, template: '' })
export class SearchComponent {
  // Signal brut mis à jour à chaque frappe
  rawQuery = signal('');
  // Signal debounced : mis à jour après 300ms de pause
  debouncedQuery = signal('');

  constructor() {
    let timeout: ReturnType<typeof setTimeout>;
    // effect() surveille rawQuery et met à jour debouncedQuery avec délai
    effect(() => {
      const query = this.rawQuery(); // tracker la dépendance
      clearTimeout(timeout);
      timeout = setTimeout(() => {
        // Après 300ms sans frappe, mettre à jour le signal debounced
        this.debouncedQuery.set(query);
      }, 300);
    });
  }

  // La requête HTTP utilise le signal debounced, pas le brut
  results = httpResource<string[]>(() => {
    const q = this.debouncedQuery();
    if (q.length < 2) return undefined; // Ne pas chercher si moins de 2 caractères
    return `/api/search?q=${encodeURIComponent(q)}`;
  });
}

reload() et mutations : quand forcer le rechargement

reload() est la méthode permettant de forcer une nouvelle requête HTTP sans changer les paramètres. C'est l'équivalent d'un bouton "actualiser". Elle est indispensable après une mutation (POST/PUT/DELETE) pour resynchroniser les données affichées.

Pattern CRUD complet avec reload() après mutation :

// todo-list.component.ts — CRUD complet avec httpResource + reload()
import { Component, signal, inject } from '@angular/core';
import { httpResource, HttpClient } from '@angular/common/http';
import { FormsModule } from '@angular/forms';

interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

@Component({
  selector: 'app-todo-list',
  standalone: true,
  imports: [FormsModule],
  template: `
    <h2>Mes tâches</h2>

    <!-- Formulaire d'ajout -->
    <form (ngSubmit)="addTodo()" class="d-flex gap-2 mb-3">
      <input
        [(ngModel)]="newTitle"
        name="title"
        placeholder="Nouvelle tâche..."
        class="form-control"
        required
      />
      <button type="submit" class="btn btn-primary" [disabled]="isSaving()">
        {{ isSaving() ? 'Ajout...' : 'Ajouter' }}
      </button>
    </form>

    <!-- Liste des tâches -->
    @if (todos.isLoading()) {
      <div class="spinner-border" role="status"></div>
    }

    @for (todo of todos.value() ?? []; track todo.id) {
      <div class="d-flex align-items-center gap-2 mb-2">
        <input
          type="checkbox"
          [checked]="todo.completed"
          (change)="toggleTodo(todo)"
          class="form-check-input"
        />
        <span [class.text-decoration-line-through]="todo.completed">
          {{ todo.title }}
        </span>
        <button (click)="deleteTodo(todo.id)" class="btn btn-sm btn-danger ms-auto">
          Supprimer
        </button>
      </div>
    }
  `
})
export class TodoListComponent {
  // HttpClient encore utile pour les mutations (POST/PUT/DELETE)
  private http = inject(HttpClient);

  newTitle = signal('');
  isSaving = signal(false);

  // httpResource gère le GET et l'état de la liste
  todos = httpResource<Todo[]>('/api/todos');

  addTodo(): void {
    const title = this.newTitle().trim();
    if (!title) return;

    this.isSaving.set(true);

    // POST avec HttpClient pour créer la tâche
    this.http.post<Todo>('/api/todos', { title, completed: false }).subscribe({
      next: () => {
        this.newTitle.set('');         // Vider le champ
        this.isSaving.set(false);
        // reload() relance le GET pour afficher la nouvelle tâche
        this.todos.reload();
      },
      error: () => this.isSaving.set(false)
    });
  }

  toggleTodo(todo: Todo): void {
    // PATCH pour mettre à jour le statut
    this.http.patch(`/api/todos/${todo.id}`, { completed: !todo.completed }).subscribe({
      next: () => {
        // Mise à jour optimiste locale via update() pour UX immédiate
        this.todos.update((list) =>
          list?.map(t => t.id === todo.id ? { ...t, completed: !t.completed } : t)
        );
        // Pas besoin de reload() car on a mis à jour localement
      }
    });
  }

  deleteTodo(id: number): void {
    this.http.delete(`/api/todos/${id}`).subscribe({
      next: () => {
        // reload() après suppression pour resynchroniser la liste
        this.todos.reload();
      }
    });
  }
}
À retenir : Pendant un reload(), le statut passe à ResourceStatus.Reloading (et non Loading). La valeur précédente reste disponible via value() pendant le rechargement — parfait pour éviter le flash blanc.

Recharger périodiquement (polling) :

// Polling : recharger les données toutes les 30 secondes
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
import { httpResource } from '@angular/common/http';

interface ServerStatus {
  status: 'ok' | 'degraded' | 'down';
  latency: number;
}

@Component({
  selector: 'app-server-status',
  standalone: true,
  template: `
    @if (status.value(); as s) {
      <span [class]="'badge bg-' + badgeColor(s.status)">
        {{ s.status.toUpperCase() }} — {{ s.latency }}ms
      </span>
    }
    <!-- Afficher l'heure du dernier rafraîchissement -->
    <small class="text-muted ms-2">Mis à jour : {{ lastUpdate() }}</small>
  `
})
export class ServerStatusComponent implements OnInit, OnDestroy {
  status = httpResource<ServerStatus>('/api/health');

  // Stocker l'ID de l'intervalle pour pouvoir le nettoyer
  private pollInterval!: ReturnType<typeof setInterval>;
  private _lastUpdate = '';

  ngOnInit(): void {
    // Lancer le polling toutes les 30 secondes
    this.pollInterval = setInterval(() => {
      this.status.reload();
      this._lastUpdate = new Date().toLocaleTimeString('fr-FR');
    }, 30_000);
  }

  ngOnDestroy(): void {
    // Arrêter le polling quand le composant est détruit
    clearInterval(this.pollInterval);
  }

  lastUpdate(): string {
    return this._lastUpdate || 'Jamais';
  }

  badgeColor(s: string): string {
    return s === 'ok' ? 'success' : s === 'degraded' ? 'warning' : 'danger';
  }
}

httpResource() vs HttpClient + RxJS : comparatif

httpResource() ne remplace pas HttpClient dans tous les cas. Les deux APIs coexistent et se complètent. Voici un guide clair pour savoir quand choisir l'une ou l'autre.

Critère httpResource() HttpClient + RxJS
Lecture de données (GET) Recommandé Fonctionne, plus verbeux
Mutations (POST/PUT/DELETE) Non supporté directement Recommandé
Requêtes réactives (signal-driven) Natif et automatique Manuel (switchMap, combineLatest)
Gestion d'état isLoading/error Intégrée (signals) Manuelle (3+ propriétés)
Opérateurs complexes (retry, debounce) Limité Puissant (tout l'écosystème RxJS)
Requêtes parallèles (forkJoin) Non supporté directement Recommandé
Mise en cache côté client À venir (ResourceCache) Manuel (shareReplay)
Courbe d'apprentissage Faible (débutants) Élevée (RxJS obligatoire)
Tests unitaires Simple (mock signal) Complexe (marble testing)

Arbre de décision — choisir la bonne API :

  • Vous chargez des données en GET pour affichage dans un template → httpResource()
  • La requête dépend d'un ou plusieurs signals → httpResource()
  • Vous faites un POST/PUT/DELETE → HttpClient + subscribe()
  • Vous enchaînez plusieurs appels API avec switchMap → HttpClient + RxJS
  • Vous avez besoin de retry avec backoff exponentiel → HttpClient + RxJS
  • Vous lancez plusieurs requêtes en parallèle avec forkJoin → HttpClient + RxJS
À retenir : Dans la pratique, httpResource() et HttpClient coexistent dans le même composant. httpResource() gère les lectures réactives, HttpClient gère les mutations. C'est le pattern recommandé par l'équipe Angular.

Migrer un composant existant : avant / après :

// AVANT — HttpClient + RxJS (25 lignes)
@Component({ selector: 'app-articles', standalone: true, template: '' })
export class ArticlesComponentBefore implements OnInit, OnDestroy {
  articles: Article[] = [];
  isLoading = false;
  error: string | null = null;
  private sub!: Subscription;

  constructor(private http: HttpClient) {}

  ngOnInit(): void {
    this.isLoading = true;
    this.sub = this.http.get<Article[]>('/api/articles').subscribe({
      next: (data) => { this.articles = data; this.isLoading = false; },
      error: (e)    => { this.error = e.message; this.isLoading = false; }
    });
  }

  ngOnDestroy(): void { this.sub.unsubscribe(); }
}
// APRÈS — httpResource() (5 lignes)
@Component({ selector: 'app-articles', standalone: true, template: '' })
export class ArticlesComponentAfter {
  // Remplace les 25 lignes précédentes par une seule
  // isLoading, error, les données et le cycle de vie sont gérés automatiquement
  articles = httpResource<Article[]>('/api/articles');
}

Gestion des erreurs et états de chargement dans le template

Une gestion robuste des erreurs est essentielle pour une bonne expérience utilisateur. httpResource() expose le signal error() qui contient l'erreur brute. Il faut la typer correctement pour afficher des messages pertinents.

Typer et afficher les erreurs HTTP :

// error-handling.component.ts — gestion complète des erreurs
import { Component, computed } from '@angular/core';
import { httpResource } from '@angular/common/http';
import { HttpErrorResponse } from '@angular/common/http';

interface Article {
  id: number;
  title: string;
  content: string;
}

@Component({
  selector: 'app-article-detail',
  standalone: true,
  template: `
    <!-- Squelette de chargement (skeleton screen) -->
    @if (article.isLoading()) {
      <div class="skeleton-wrapper">
        <div class="skeleton-title"></div>
        <div class="skeleton-text"></div>
        <div class="skeleton-text short"></div>
      </div>
    }

    <!-- Affichage des données -->
    @if (article.value(); as a) {
      <article>
        <h1>{{ a.title }}</h1>
        <p>{{ a.content }}</p>
      </article>
    }

    <!-- Message d'erreur contextualisé -->
    @if (errorMessage()) {
      <div class="alert alert-danger d-flex align-items-center gap-2" role="alert">
        <i class="bi bi-exclamation-triangle-fill"></i>
        <div>
          {{ errorMessage() }}
          <button class="btn btn-sm btn-outline-danger ms-2" (click)="article.reload()">
            Réessayer
          </button>
        </div>
      </div>
    }
  `
})
export class ArticleDetailComponent {
  // Charger l'article avec l'ID 1
  article = httpResource<Article>('/api/articles/1');

  // Signal computed : transformer l'erreur brute en message lisible
  errorMessage = computed(() => {
    const err = this.article.error();
    if (!err) return null; // Pas d'erreur

    // Typer l'erreur comme HttpErrorResponse pour accéder au status
    if (err instanceof HttpErrorResponse) {
      switch (err.status) {
        case 0:   return 'Connexion impossible. Vérifiez votre réseau.';
        case 401: return 'Vous devez être connecté pour accéder à cet article.';
        case 403: return 'Accès refusé. Vous n\'avez pas les permissions nécessaires.';
        case 404: return 'Article introuvable.';
        case 500: return 'Erreur serveur. Veuillez réessayer dans quelques instants.';
        default:  return `Erreur ${err.status} : ${err.statusText}`;
      }
    }

    // Erreur réseau ou autre
    return 'Une erreur inattendue est survenue.';
  });
}

Template de chargement avec état "Reloading" distinct :

// Distinguer le premier chargement du rechargement
// pour une UX plus fine
import { Component, computed } from '@angular/core';
import { httpResource, ResourceStatus } from '@angular/common/http';

@Component({
  selector: 'app-feed',
  standalone: true,
  template: `
    <!-- Barre de progression fine en haut — visible seulement pendant reload -->
    @if (isReloading()) {
      <div class="progress" style="height: 3px; margin-bottom: 1rem;">
        <div class="progress-bar progress-bar-animated progress-bar-striped w-100"></div>
      </div>
    }

    <!-- Spinner pleine page seulement au premier chargement -->
    @if (isFirstLoad()) {
      <div class="d-flex justify-content-center py-5">
        <div class="spinner-border text-primary" role="status">
          <span class="visually-hidden">Chargement...</span>
        </div>
      </div>
    } @else {
      <!-- Afficher les données même pendant un reload (évite le flash blanc) -->
      @for (item of feed.value() ?? []; track item.id) {
        <div class="card mb-3">
          <div class="card-body">{{ item.title }}</div>
        </div>
      }
    }

    <button (click)="feed.reload()" [disabled]="feed.isLoading()" class="btn btn-secondary">
      Actualiser le fil
    </button>
  `
})
export class FeedComponent {
  feed = httpResource<{ id: number; title: string }[]>('/api/feed');

  // Vrai uniquement pendant le tout premier chargement (pas de valeur encore)
  isFirstLoad = computed(
    () => this.feed.status() === ResourceStatus.Loading && !this.feed.value()
  );

  // Vrai lors des rechargements suivants (valeur déjà présente)
  isReloading = computed(
    () => this.feed.status() === ResourceStatus.Reloading
  );
}

Checklist de robustesse

  • Toujours afficher un état de chargement (isLoading() ou status())
  • Toujours afficher un message d'erreur contextualisé (error())
  • Toujours proposer un bouton "Réessayer" appelant reload()
  • Utiliser ?? [] ou ?? null sur value() pour éviter les erreurs de template
  • Distinguer Loading (premier chargement) et Reloading pour une UX fine
  • Typer l'erreur en HttpErrorResponse pour des messages précis
  • Ne jamais bloquer l'UI pendant un rechargement : conserver la valeur affichée
  • Utiliser computed() pour transformer les signaux dérivés (labels, couleurs…)
  • Retourner undefined depuis la fonction URL pour suspendre la requête si nécessaire
  • Tester les états isLoading, error et value dans les specs Jasmine/Jest

Tester un composant utilisant httpResource() :

// article-detail.component.spec.ts — tester les états du resource
import { TestBed } from '@angular/core/testing';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { provideHttpClient } from '@angular/common/http';
import { ArticleDetailComponent } from './article-detail.component';

describe('ArticleDetailComponent', () => {
  let httpMock: HttpTestingController;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [ArticleDetailComponent],
      providers: [
        // Fournir le client HTTP et son intercepteur de test
        provideHttpClient(),
        provideHttpClientTesting()
      ]
    }).compileComponents();

    httpMock = TestBed.inject(HttpTestingController);
  });

  afterEach(() => {
    // Vérifier qu'aucune requête inattendue n'est en attente
    httpMock.verify();
  });

  it('doit afficher les données après chargement', () => {
    const fixture = TestBed.createComponent(ArticleDetailComponent);
    const component = fixture.componentInstance;

    // isLoading doit être true immédiatement
    expect(component.article.isLoading()).toBeTrue();

    // Simuler la réponse API
    const mockArticle = { id: 1, title: 'Test', content: 'Contenu...' };
    httpMock.expectOne('/api/articles/1').flush(mockArticle);

    fixture.detectChanges(); // Déclencher la détection de changements

    // Les données doivent maintenant être disponibles
    expect(component.article.value()).toEqual(mockArticle);
    expect(component.article.isLoading()).toBeFalse();
    expect(component.article.error()).toBeUndefined();
  });

  it('doit exposer une erreur 404 via error()', () => {
    const fixture = TestBed.createComponent(ArticleDetailComponent);
    const component = fixture.componentInstance;

    // Simuler une erreur 404
    httpMock.expectOne('/api/articles/1').flush(
      { message: 'Not Found' },
      { status: 404, statusText: 'Not Found' }
    );

    fixture.detectChanges();

    // L'erreur doit être exposée par le signal error()
    expect(component.article.error()).toBeTruthy();
    expect(component.article.value()).toBeUndefined();
    // Le message computed doit retourner 'Article introuvable.'
    expect(component.errorMessage()).toBe('Article introuvable.');
  });
});

Conclusion : l'avenir HTTP signal-based dans Angular

httpResource() représente une évolution majeure dans la façon de gérer les requêtes HTTP dans Angular. En remplaçant le pattern verbeux HttpClient + subscribe + ngOnDestroy par une API déclarative signal-based, Angular réduit considérablement le boilerplate tout en améliorant la lisibilité et la robustesse du code.

La puissance réelle de httpResource() réside dans sa réactivité automatique : en passant une fonction qui lit des signals, les requêtes se relancent naturellement à chaque changement de paramètre, sans aucun code de gestion manuelle. C'est particulièrement précieux pour les interfaces de filtrage, de recherche et de pagination.

La cohabitation avec HttpClient reste nécessaire pour les mutations (POST, PUT, DELETE) et les cas avancés nécessitant les opérateurs RxJS. La stratégie recommandée est simple : httpResource() pour les lectures, HttpClient pour les écritures.

À retenir : Adoptez httpResource() pour tout nouveau composant Angular qui charge des données. Migrez progressivement les composants existants en commençant par les plus simples (GET sans side-effects). La réduction de boilerplate et la clarté du code en valent largement l'effort.

Partager