Développement web et mobile

Les bases d'Angular : composants standalone, signals et API modernes

Intermédiaire10 min de lectureMis à jour

Cette page a été traduite de l'anglais avec l'aide de l'IA, puis vérifiée par rapport à l'original. Lire la version anglaise

Angular a profondément changé entre l'époque des NgModules et les versions actuelles : les composants sont standalone par défaut, l'état passe par des signals, les templates utilisent des blocs de control flow intégrés et la détection de changements peut fonctionner sans Zone.js. Ce guide présente le framework tel qu'il est aujourd'hui, d'un premier composant jusqu'à l'injection de dépendances, HTTP, le routage, les formulaires réactifs et le modèle de détection de changements qui relie le tout.

Ce que vous allez apprendre

  • Créer des composants standalone et comprendre comment une application démarre sans NgModules
  • Lier des données dans les templates avec les blocs de control flow intégrés et les signal inputs
  • Modéliser l'état avec des signals, des valeurs calculées et des effets, et savoir quand RxJS reste le meilleur outil
  • Injecter des services, configurer HttpClient et le routeur avec des fonctions de provider
  • Construire et valider des formulaires réactifs, et expliquer comment la détection de changements décide quoi re-rendre

Si votre dernier contact avec Angular remonte à l'époque des NgModule, des décorateurs @Input() et de *ngFor, le framework que vous découvrirez aujourd'hui aura changé dans presque chaque fichier. Les composants sont standalone, l'état est porté par des signals, les templates utilisent une syntaxe en blocs pour les conditions et les boucles, les providers viennent de fonctions et non plus des métadonnées de modules, et la détection de changements peut se passer complètement de Zone.js. L'architecture (composants, templates, injection de dépendances, routeur) reste reconnaissable ; l'ergonomie, elle, n'a plus grand-chose à voir.

Ce guide présente la forme actuelle du framework plutôt que sa forme historique. Il part d'un seul composant, ajoute l'état, les services, HTTP et le routage, puis explique le modèle de détection de changements qui rend l'ensemble prévisible. Tout ce qui suit suppose TypeScript, qu'Angular ne se contente pas de tolérer mais dont il dépend : les métadonnées des décorateurs, les tokens d'injection et les formulaires typés reposent tous dessus.

La brique de base d'une application : le composant

Un composant est une classe TypeScript dotée d'un décorateur qui la lie à un template et à des styles. Rien d'autre n'est nécessaire : aucun module ne le déclare, aucun fichier barrel ne l'enregistre.

import { Component, signal, computed, input, output } from '@angular/core';

@Component({
  selector: 'app-order-row',
  template: `
    <tr [class.selected]="selected()">
      <td>{{ order().reference }}</td>
      <td>{{ order().total | currency: 'EUR' }}</td>
      <td><button type="button" (click)="remove.emit(order().id)">Remove</button></td>
    </tr>
  `,
  styles: `.selected { background: var(--highlight); }`,
})
export class OrderRowComponent {
  readonly order = input.required<Order>();
  readonly selected = input(false);
  readonly remove = output<string>();
}

Plusieurs points méritent d'être soulignés. input() et output() sont des fonctions et non des décorateurs, et input.required<T>() fait vérifier par le compilateur que l'appelant fournit bien la valeur. Un input créé de cette façon est un signal : vous le lisez en l'appelant, et tout ce qui en dérive se met à jour automatiquement. Le sélecteur correspond à la balise utilisée là où le composant est inséré ; le tableau imports (absent ici, car le template n'utilise aucun autre composant) est l'endroit où un composant standalone liste les composants, directives et pipes dont son template a besoin.

TypeScript joue ici un vrai rôle. Les interfaces décrivent les structures qui franchissent les frontières entre composants, les génériques typent les inputs et les réponses HTTP, et activer strict dans tsconfig.json fait toute la différence entre un null attrapé par le compilateur et un null signalé par le navigateur.

Une application démarre en amorçant un composant racine avec une liste de providers :

import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';

bootstrapApplication(AppComponent, {
  providers: [
    provideRouter(routes, withComponentInputBinding()),
    provideHttpClient(withFetch(), withInterceptors([authInterceptor])),
  ],
});

Templates : bindings et control flow en blocs

Les templates Angular sont du HTML doté de quatre formes de binding. L'interpolation entre doubles accolades affiche une valeur sous forme de texte. Les crochets lient une propriété : [disabled]="isBusy()" définit la propriété DOM, pas l'attribut. Les parenthèses lient un événement : (click)="save()". Enfin, la « banane dans la boîte » [(ngModel)] combine les deux sens, et n'est qu'un raccourci pour un binding de propriété plus un binding d'événement.

La logique structurelle utilise désormais des blocs intégrés à la place des anciennes directives à astérisque :

@Component({
  selector: 'app-orders',
  template: `
    @if (orders().length > 0) {
      <table>
        @for (order of orders(); track order.id) {
          <app-order-row [order]="order" (remove)="delete($event)" />
        } @empty {
          <tr><td>Nothing to show</td></tr>
        }
      </table>
    } @else {
      <p>Loading…</p>
    }

    @switch (status()) {
      @case ('draft')     { <app-draft-banner /> }
      @case ('submitted') { <app-submitted-banner /> }
      @default            { <app-unknown-banner /> }
    }

    @defer (on viewport) {
      <app-revenue-chart [data]="chartData()" />
    } @placeholder {
      <div class="skeleton"></div>
    }
  `,
  imports: [OrderRowComponent, DraftBannerComponent],
})
export class OrdersComponent { /* … */ }

Ces blocs font partie du langage de template et ne sont pas des directives : il n'y a rien à importer, le compilateur peut vérifier les types à l'intérieur et le code produit est plus petit et plus rapide. L'expression track de @for est obligatoire, et elle compte : elle indique à Angular comment faire correspondre les éléments d'un rendu à l'autre, afin qu'il puisse déplacer les nœuds DOM au lieu de les détruire et de les reconstruire. Suivre par index une liste qui se réordonne est une source classique de saisies perdues. @defer est une capacité réellement nouvelle : il place tout son contenu dans un chunk séparé et ne le charge qu'au déclencheur choisi — l'entrée dans le viewport, un survol ou un callback d'inactivité.

Les pipes transforment une valeur dans le template. currency, date, json, async et keyvalue couvrent l'essentiel des besoins, et un pipe pur personnalisé est un moyen peu coûteux de formater sans ajouter un appel de méthode à chaque passe de détection de changements.

Signals : un état que le framework sait suivre

Un signal est un conteneur de valeur qui enregistre qui le lit. Quand il change, exactement ces lecteurs-là sont notifiés, et rien d'autre n'est réévalué.

import { Component, signal, computed, effect } from '@angular/core';

@Component({ /* … */ })
export class CartComponent {
  readonly items = signal<CartItem[]>([]);
  readonly coupon = signal<string | null>(null);

  readonly subtotal = computed(() =>
    this.items().reduce((sum, item) => sum + item.price * item.quantity, 0),
  );
  readonly total = computed(() =>
    this.coupon() ? this.subtotal() * 0.9 : this.subtotal(),
  );

  constructor() {
    effect(() => localStorage.setItem('cart', JSON.stringify(this.items())));
  }

  add(item: CartItem) {
    this.items.update((current) => [...current, item]);   // new array, not push
  }
}

On lit un signal en l'appelant. On écrit avec set pour un remplacement, ou avec update pour une fonction de la valeur précédente. computed dérive une valeur de façon paresseuse et la met en cache jusqu'à ce qu'une de ses dépendances change : total ci-dessus n'est donc recalculé que lorsque items ou coupon change. effect exécute des effets de bord quand ses dépendances changent ; il sert à se synchroniser avec ce qui vit hors d'Angular (stockage, bibliothèque de graphiques, logs), pas à réécrire dans d'autres signals.

La règle qui fait fonctionner tout cela, c'est l'immutabilité. this.items.update(list => [...list, item]) produit un nouveau tableau et notifie les lecteurs ; this.items().push(item) modifie le tableau en place et ne notifie personne. C'est la même discipline que celle décrite dans les fondamentaux de JavaScript, sauf qu'ici le framework en dépend.

Les signals alimentent aussi les API de query et de model : viewChild() et contentChild() renvoient des signals de l'élément trouvé, et model() crée un signal input liable dans les deux sens.

Injection de dépendances et services

L'injecteur d'Angular est hiérarchique : il part du composant et remonte par les composants parents jusqu'à la racine de l'application. Un service est une classe ordinaire marquée comme injectable, et providedIn: 'root' en fait un singleton éliminé par le tree-shaking si personne ne l'injecte.

import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';

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

  list(page: number) {
    return this.http.get<Page<Order>>('/api/orders', { params: { page } });
  }

  create(draft: OrderDraft) {
    return this.http.post<Order>('/api/orders', draft);
  }
}

La fonction inject() a largement remplacé l'injection par paramètres de constructeur. Elle fonctionne dans les initialiseurs de champs, dans les fonctions factory et dans les helpers provide*, et elle se compose : vous pouvez écrire une petite fonction réutilisable qui appelle elle-même inject(). L'injection par constructeur fonctionne toujours, et c'est encore ce que vous verrez dans les fichiers plus anciens.

Les tokens d'injection permettent d'injecter ce qui n'est pas une classe (un objet de configuration, une URL de base, un feature flag), et fournir une autre implémentation pour le même token est la façon de remplacer un vrai service par un fake dans les tests, ou par un backend simulé en développement.

HTTP, et où vont les erreurs

HttpClient renvoie des observables froids : rien n'est envoyé tant que personne ne s'abonne, et se désabonner annule la requête. Configurez-le une fois avec provideHttpClient(withFetch()), qui s'appuie sur l'implémentation fetch de la plateforme.

this.orders.list(1).subscribe({
  next: (page) => this.items.set(page.content),
  error: (error: HttpErrorResponse) => this.message.set(describe(error.status)),
});

Dans l'API actuelle, les intercepteurs sont de simples fonctions, et ils sont le bon endroit pour les en-têtes d'authentification, les identifiants de corrélation, la politique de retry et la conversion centralisée des erreurs :

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const token = inject(TokenStore).current();
  return next(token ? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }) : req);
};

Contrairement à fetch, HttpClient traite une réponse non-2xx comme une erreur : la gestion des statuts se fait donc dans le callback d'erreur. Décider de ce que chaque code signifie pour l'utilisateur (relancer, se réauthentifier, afficher un message de validation) mérite d'être fait délibérément ; la référence des codes de statut HTTP détaille les distinctions, et la conception d'API REST couvre le côté serveur du même contrat.

Les versions récentes ajoutent une API resource qui enveloppe un chargement asynchrone dans des signals : elle expose la valeur, l'état de chargement et l'erreur sous forme de signals, et relance la requête quand son signal de requête change. Elle supprime une bonne partie du boilerplate du type « charger ceci quand cela change », et il vaut la peine de la regarder dans la documentation actuelle avant de réécrire la même chose à la main.

Routage

Les routes sont un simple tableau, et le lazy loading est un import dynamique :

export const routes: Routes = [
  { path: '', component: HomeComponent, title: 'Home' },
  {
    path: 'orders',
    loadChildren: () => import('./orders/routes').then((m) => m.ORDER_ROUTES),
    canActivate: [authGuard],
  },
  { path: 'orders/:id', loadComponent: () => import('./orders/detail').then((m) => m.OrderDetailComponent) },
  { path: '**', component: NotFoundComponent },
];

loadComponent et loadChildren découpent le bundle à la frontière de la route, ce qui est la décision de performance la plus rentable dans une grosse application. Les guards sont des fonctions qui renvoient un booléen, un UrlTree ou un observable de l'un ou de l'autre. Avec withComponentInputBinding() activé, les paramètres de route, les paramètres de requête et les données résolues sont liés directement aux inputs du composant : un composant qui lit id = input.required<string>() n'a alors aucun besoin d'injecter le routeur.

Formulaires réactifs

Les formulaires réactifs définissent le modèle en TypeScript, ce qui le rend typé et testable sans rien rendre.

private readonly fb = inject(NonNullableFormBuilder);

readonly form = this.fb.group({
  reference: this.fb.control('', [Validators.required, Validators.maxLength(20)]),
  email: this.fb.control('', [Validators.required, Validators.email]),
  quantity: this.fb.control(1, [Validators.min(1), Validators.max(50)]),
});

submit() {
  if (this.form.invalid) {
    this.form.markAllAsTouched();
    return;
  }
  this.orders.create(this.form.getRawValue()).subscribe(/* … */);
}

Chaque contrôle expose value, valid, errors, dirty, touched et un observable valueChanges. Les validateurs personnalisés sont des fonctions qui renvoient une map d'erreurs ou null, et les validateurs asynchrones renvoient un observable : c'est la façon habituelle de vérifier une valeur auprès du serveur pendant la saisie, avec un debounce. N'affichez une erreur qu'une fois le contrôle touched ou le formulaire soumis ; valider dès le premier caractère tapé est l'erreur d'ergonomie la plus fréquente dans les formulaires Angular.

Comment la détection de changements décide quoi re-rendre

Historiquement, Angular s'appuyait sur Zone.js, qui monkey-patche les timers, les écouteurs d'événements et XHR pour que le framework sache quand un traitement asynchrone se termine. À chacun de ces événements, il parcourt l'arbre des composants depuis la racine et réévalue chaque binding de chaque template. C'est pour cela que le framework paraissait « automatique », et aussi pour cela que les écrans chargés devenaient lents.

ChangeDetectionStrategy.OnPush restreint ce parcours : un composant n'est vérifié que lorsqu'un input change de référence, qu'un événement naît en son sein, qu'un signal qu'il lit change ou que quelque chose le marque comme modifié. Combiné à des mises à jour immuables, cela transforme le parcours de tout l'arbre en un chemin allant de la racine aux seuls composants qui ont réellement changé.

Les signals vont plus loin, car en lire un dans un template enregistre une dépendance précise. Le parcours de l'arbre devient alors inutile, et c'est ce qui permet la détection de changements zoneless : avec provideZonelessChangeDetection(), l'application abandonne entièrement la dépendance à Zone.js et planifie une vérification quand un signal change ou qu'un événement se déclenche. Un bundle plus léger, des stack traces plus lisibles, et fini les cycles supplémentaires mystérieux. Cela exige que tout l'état lu par le template soit soit un signal, soit quelque chose qui marque explicitement le composant comme modifié ; c'est pourquoi le code « signals d'abord » est l'approche recommandée par défaut pour les nouvelles applications.

RxJS, organisation du projet et tests

RxJS n'a pas disparu ; son périmètre s'est resserré sur ce qu'il fait vraiment bien, les flux dans le temps. Appliquer un debounce à un champ de recherche avec debounceTime, annuler une requête devenue obsolète avec switchMap, relancer avec un backoff, combiner plusieurs sources : rien de tout cela n'a d'équivalent en signals. La passerelle idiomatique est toSignal(), qui transforme un observable en signal que le template peut lire sans le pipe async et sans désabonnement manuel. Pour un état de composant qui se résume à « la valeur actuelle de quelque chose », les signals demandent moins de machinerie et laissent moins de place à l'erreur.

La CLI génère et maintient le projet. ng new, ng generate component orders/order-row, ng serve, ng build, ng test et ng update couvrent le travail quotidien, et ng update exécute des schematics qui réécrivent votre code en fonction des breaking changes ; c'est pourquoi rester proche de la version courante coûte bien moins cher que d'en sauter plusieurs. Organisez le code par fonctionnalité plutôt que par type : un dossier orders contenant ses composants, son service, ses routes et ses modèles vaut mieux que des arborescences parallèles components et services qui obligent chaque modification à toucher quatre répertoires.

Côté tests, TestBed configure un injecteur miniature pour qu'un composant puisse être rendu avec des providers factices, tandis qu'un service doté de dépendances injectées peut généralement être testé comme une simple classe, sans aucune intervention du framework. Testez la logique pilotée par les signals en définissant des inputs et en lisant les valeurs calculées ; gardez les tests end-to-end pour les parcours qui traversent plusieurs composants.

À retenir

  • Les composants standalone sont la norme : pas de NgModules, bootstrapApplication à la racine, et des providers fournis par des fonctions provide*.
  • Les templates utilisent le control flow en blocs (@if, @for avec un track obligatoire, @switch et @defer), que le compilateur comprend directement.
  • Les signals portent l'état, computed le dérive de façon paresseuse et effect se synchronise avec le monde extérieur ; les mises à jour doivent être immuables pour que tout cela fonctionne.
  • inject() est la façon moderne d'accéder aux services, les intercepteurs sont de simples fonctions, et les observables de HttpClient sont froids et annulables.
  • OnPush et les signals réduisent la détection de changements d'un parcours complet de l'arbre à une mise à jour ciblée, et c'est ce qui rend possibles les applications zoneless.
  • Gardez RxJS pour les flux dans le temps et faites le pont avec toSignal() ; organisez par fonctionnalité et laissez ng update vous faire passer d'une version à l'autre.

Sources et lectures complémentaires

Questions fréquentes

Ai-je encore besoin des NgModules ?
Pas pour du code neuf. Les composants, directives et pipes sont standalone par défaut, les applications démarrent avec bootstrapApplication et les providers sont fournis par des fonctions comme provideRouter et provideHttpClient. Les NgModules restent pris en charge pour que les grosses applications existantes continuent de fonctionner et puissent migrer progressivement, et certaines bibliothèques tierces en livrent encore, mais rien dans un nouveau projet n'oblige à en déclarer un.
Les signals remplacent-ils RxJS ?
Ils se recoupent, mais ne résolvent pas les mêmes problèmes. Les signals modélisent un état synchrone lu par le template, avec un suivi automatique des dépendances et sans gestion d'abonnements. RxJS modélise des flux d'événements dans le temps et fournit des opérateurs pour faire du debounce, relancer, annuler et combiner du travail asynchrone. Le code moderne typique utilise les signals pour l'état des composants et RxJS pour les pipelines HTTP et d'événements, avec toSignal comme passerelle.
Que fait réellement la détection de changements OnPush ?
Par défaut, Angular vérifie chaque composant de l'arbre dès qu'un traitement asynchrone se termine. Avec OnPush, un composant n'est vérifié que si l'un de ses inputs change de référence, si un événement se déclenche en son sein, si un signal qu'il lit change ou s'il est explicitement marqué comme modifié. Sur un écran chargé, cela supprime l'essentiel du travail à chaque cycle. Les composants à base de signals rendent ce comportement prévisible, et c'est ce qui permet les applications zoneless.
Formulaires réactifs ou formulaires pilotés par le template ?
Préférez les formulaires réactifs dès que vous dépassez le simple champ de recherche à deux entrées. Le modèle du formulaire est défini en TypeScript : il est donc typé, testable unitairement sans rendu et facile à composer, et les contrôles dynamiques ou les validateurs inter-champs ne posent pas de difficulté. Les formulaires pilotés par le template placent le modèle dans le balisage avec ngModel, ce qui va plus vite pour les cas triviaux mais devient difficile à tester et à comprendre à mesure que la validation s'étoffe.