2. Premiers pas avec Angular
2.1. Exemple 01 : la structure d'une application Angular
2.1.1. Le fichier package.json
Le fichier [package.json] du dossier des exemples déclare les bibliothèques utilisées et les commandes du projet :
| {
"name": "exemples-angular",
"version": "1.0.0",
"description": "Les petits exemples du cours [Introduction étape par étape au framework Angular] - une seule installation pour tous",
"private": true,
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build"
},
"dependencies": {
"@angular/common": "^22.2.0",
"@angular/compiler": "^22.2.0",
"@angular/core": "^22.2.0",
"@angular/forms": "^22.2.0",
"@angular/platform-browser": "^22.2.0",
"@angular/router": "^22.2.0",
"@ngx-translate/core": "^18.0.0",
"bootstrap": "^5.3.8",
"rxjs": "~7.8.0",
"tslib": "^2.8.0"
},
"devDependencies": {
"@angular/build": "^22.2.0",
"@angular/cli": "^22.2.0",
"@angular/compiler-cli": "^22.2.0",
"typescript": "~6.0.2"
}
}
|
Commentons ce fichier :
- lignes 6-10 : les commandes du projet, lancées par [npm run <nom>] (ou [npm start] pour la commande [start]) :
- [npm start 05-ngmodel] lance [ng serve 05-ngmodel] : le serveur de développement, pour l'exemple 05 ;
- [npm run build 05-ngmodel] lance [ng build 05-ngmodel] : la version de production de l'exemple 05, dans le dossier [dist/05-ngmodel/] ;
- [npm run ng -- <arguments>] donne accès à toutes les commandes de l'outil [ng] ;
- lignes 11-22 : les bibliothèques dont l'application a besoin dans le navigateur : les paquets d'[Angular] ([@angular/core], le cœur ; [@angular/common], les outils courants dont le client HTTP ; [@angular/forms], les formulaires ; [@angular/router], le routeur ; [@angular/platform-browser], le démarrage dans un navigateur ; [@angular/compiler], le compilateur des templates), [ngx-translate] pour les langues, [Bootstrap] pour la mise en forme, [RxJS] pour les observables et [tslib], une petite bibliothèque d'aide utilisée par le code que produit [TypeScript] ;
- lignes 23-28 : les outils de développement : l'outil en ligne de commande [ng] ([@angular/cli]), le compilateur d'[Angular] ([@angular/compiler-cli]), l'outil de construction ([@angular/build], qui s'appuie sur [esbuild] et [Vite]), le compilateur [TypeScript]. Ils ne font pas partie de l'application livrée aux utilisateurs.
Le préfixe [^] d'une version ([^22.2.0]) autorise [npm] à installer une version plus récente de la même version majeure (22.x.y) ; le préfixe [~] ([~6.0.2]) n'autorise que les corrections (6.0.x). Tous les paquets [@angular/...] doivent avoir la même version.
Dans un projet ordinaire, ce fichier est créé par la commande [ng new nom-du-projet], qui fabrique une application [Angular] complète, prête à être lancée. Les exemples de ce cours ont été écrits à la main, pour pouvoir en réunir vingt-cinq dans un même dossier.
2.1.2. Le fichier angular.json
[angular.json] décrit l'espace de travail : les applications qu'il contient, et comment les construire et les servir. Son début, qui décrit l'exemple 01 :
| {
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm",
"analytics": false
},
"projects": {
"01-premiere-application": {
"projectType": "application",
"root": "01-premiere-application",
"sourceRoot": "01-premiere-application/src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"outputPath": "dist/01-premiere-application",
"index": "01-premiere-application/src/index.html",
"browser": "01-premiere-application/src/main.ts",
"tsConfig": "01-premiere-application/tsconfig.app.json",
"styles": [
"node_modules/bootstrap/dist/css/bootstrap.min.css"
]
},
"configurations": {
"production": {
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"options": {
"proxyConfig": "proxy.conf.json"
},
"configurations": {
"production": {
"buildTarget": "01-premiere-application:build:production"
},
"development": {
"buildTarget": "01-premiere-application:build:development"
}
},
"defaultConfiguration": "development"
}
}
},
|
- lignes 4-7 : les réglages de l'outil [ng] (le gestionnaire de paquets, et pas de collecte de statistiques d'utilisation) ;
- ligne 9 : le nom du projet, celui qu'on donne à [ng serve] ou à [npm start] ;
- lignes 11-12 : le dossier du projet, et celui de ses sources ;
- ligne 13 : le préfixe des sélecteurs des composants ([app-root], [app-carte-medecin]...) ;
- lignes 15-37 : la construction de l'application ([ng build]) :
- ligne 16 : l'outil de construction ;
- ligne 18 : le dossier du résultat ;
- lignes 19-20 : la page HTML de l'application et son point d'entrée [TypeScript] ;
- ligne 21 : la configuration [TypeScript] du projet ;
- lignes 22-24 : les feuilles de style globales de l'application : ici, celle de [Bootstrap] ;
- lignes 26-36 : deux configurations : [production] (code optimisé ; [outputHashing] met une empreinte du contenu dans le nom de chaque fichier) et [development] (code lisible, avec les « source maps » qui permettent de déboguer le [TypeScript] dans le navigateur). [ng build] utilise la première par défaut (ligne 36) ;
- lignes 38-52 : le serveur de développement ([ng serve]) :
- ligne 41 : le fichier du proxy, qui ne servira qu'à partir de l'exemple 22 : toute requête du navigateur vers une adresse commençant par [/api] est transmise au serveur JSON [RdvMedecins]. Nous y reviendrons ;
- ligne 51 : [ng serve] construit l'application en configuration [development].
Les vingt-quatre autres exemples sont décrits de la même manière, dans la suite du fichier (plus de mille lignes au total). Un projet ordinaire, créé par [ng new], n'en contient qu'un.
2.1.3. Les fichiers tsconfig.json et tsconfig.app.json
La configuration [TypeScript] commune à tous les exemples :
| {
"compilerOptions": {
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"skipLibCheck": true,
"isolatedModules": true,
"experimentalDecorators": true,
"importHelpers": true,
"resolveJsonModule": true,
"target": "ES2022",
"module": "preserve"
},
"angularCompilerOptions": {
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"include": ["*/src/**/*.ts", "commun/**/*.ts"]
}
|
- ligne 3 : le mode strict de [TypeScript] (types vérifiés rigoureusement, [null] et [undefined] compris) ;
- lignes 4-9 : des vérifications supplémentaires ; en particulier, une variable ou un paramètre inutilisé est signalé comme une erreur ;
- ligne 12 : [Angular] utilise les décorateurs de [TypeScript] ([@Component], [@Injectable]...) ;
- ligne 14 : on pourra importer un fichier JSON comme un module (exemple 21) ;
- lignes 15-16 : du code [JavaScript] moderne (ES2022), et des modules traités par l'outil de construction ;
- lignes 18-22 : les vérifications propres au compilateur d'[Angular]. La plus importante, ligne 21 : les templates sont vérifiés comme du [TypeScript]. Une faute de frappe dans un template ([{{ titr }}] au lieu de [{{ titre }}]) est une erreur de compilation, signalée par [ng serve] et par l'éditeur ;
- ligne 23 : les fichiers vus par l'éditeur ([VS Code]) : ceux de tous les exemples.
Chaque exemple a en plus son propre fichier [tsconfig.app.json], qui reprend cette configuration et ne compile que SES fichiers :
| {
"extends": "../tsconfig.json",
"include": ["src/**/*.ts"]
}
|
Ainsi, une erreur dans un exemple n'empêche pas les autres de fonctionner.
2.1.4. La page index.html
Chaque exemple a sa page HTML. Celle de l'exemple 01 :
| <!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>Exemple 01 - une première application</title>
<!-- base href : l'adresse de départ des chemins relatifs (cf. le routeur) -->
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<!-- l'application Angular s'affiche dans cet élément (cf. src/main.ts) -->
<app-root></app-root>
</body>
</html>
|
C'est la seule page que le navigateur chargera. Elle est presque vide :
- ligne 7 : l'adresse de base des chemins relatifs de la page ; le routeur (exemple 17) s'en sert ;
- ligne 12 : l'élément dans lequel [Angular] affichera l'application. Ce n'est pas une balise HTML connue : c'est le sélecteur du composant racine de l'application.
On n'y trouve aucune balise [<script>] ni [<link>] : l'outil de construction les ajoute lui-même (le programme compilé, la feuille de style de [Bootstrap]).
2.1.5. Le point d'entrée : main.ts
| // =========================================================================
// 01-premiere-application/src/main.ts - le point d'entrée de l'application
// -------------------------------------------------------------------------
// angular.json désigne ce fichier comme point d'entrée ("browser"). Il
// démarre l'application Angular à partir de son composant RACINE (App) :
// Angular cherche dans index.html l'élément <app-root> (le "sélecteur" de
// App) et y affiche le HTML du composant.
// =========================================================================
import { bootstrapApplication } from '@angular/platform-browser';
import { App } from './app/app';
// bootstrapApplication(App) renvoie une promesse : on affiche une éventuelle
// erreur de démarrage dans la console du navigateur
bootstrapApplication(App).catch((erreur) => console.error(erreur));
|
- ligne 10 : la fonction d'[Angular] qui démarre une application dans un navigateur ;
- ligne 11 : le composant racine de l'application, la classe [App] du fichier [app/app.ts] ;
- ligne 15 : l'application est démarrée à partir de ce composant : [Angular] cherche dans la page l'élément [<app-root>] et y affiche le HTML de [App].
2.1.6. Le composant racine : app.ts, app.html, app.css
Un composant [Angular] est une classe [TypeScript] décorée par [@Component] :
| // =========================================================================
// 01-premiere-application/src/app/app.ts - un premier composant
// -------------------------------------------------------------------------
// Un composant Angular est une CLASSE TypeScript, décorée par @Component :
// - selector : la balise HTML qui le représente (<app-root>) ;
// - templateUrl : le fichier de son HTML (app.html) ;
// - styleUrl : le fichier de son CSS (app.css), qui ne s'applique
// qu'à ce composant.
// Les propriétés et méthodes de la classe sont utilisables dans le template.
// =========================================================================
import { Component, signal } from '@angular/core';
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
})
export class App {
// des données "ordinaires" : le template peut les afficher
protected readonly titre = 'Bienvenue dans Angular';
protected readonly langages = ['TypeScript', 'HTML', 'CSS'];
// signal(...) : une donnée RÉACTIVE. On lit sa valeur en l'APPELANT
// (compteur()), on la change par set() ou update(). Angular met alors à
// jour AUTOMATIQUEMENT les endroits de la page qui l'affichent.
protected readonly compteur = signal(0);
protected readonly heure = signal(new Date().toLocaleTimeString());
// une méthode appelée par le bouton du template
protected incrementer(): void {
this.compteur.update((n) => n + 1);
this.heure.set(new Date().toLocaleTimeString());
}
}
|
- lignes 14-18 : le décorateur [@Component] fait de la classe un composant :
- ligne 15 : son sélecteur, la balise qui le représente dans un template ou dans [index.html] ;
- ligne 16 : le fichier de son template HTML ;
- ligne 17 : le fichier de son style CSS ;
- lignes 21-22 : deux données ordinaires. [protected] : elles ne sont utilisées que par le composant et par son template ; [readonly] : elles ne seront pas remplacées ;
- ligne 27 : [signal(0)] crée une donnée réactive, de valeur initiale 0. Un signal est une fonction : on lit sa valeur en l'appelant ([this.compteur()]), et on la change par [set(valeur)] ou [update(fonction)]. Quand elle change, [Angular] met à jour tous les endroits de la page qui l'affichent ;
- lignes 31-34 : une méthode qui modifie deux signaux. Elle ne touche pas à la page : c'est [Angular] qui la mettra à jour. [update((n) => n + 1)] calcule la nouvelle valeur à partir de l'ancienne.
Le template du composant :
| <!-- 01-premiere-application/src/app/app.html - le template du composant App -->
<div class="container py-4">
<!-- {{ expression }} : la valeur de l'expression, insérée dans la page -->
<h1 class="h3">{{ titre }}</h1>
<p>Cette page est fabriquée dans le navigateur, par Angular, à partir de index.html, main.ts et du composant App.</p>
<p>On y écrit du {{ langages.join(', ') }} ({{ langages.length }} langages).</p>
<!-- (click) : la méthode à appeler quand on clique (cf. exemple 04) -->
<button class="btn btn-primary" (click)="incrementer()">Cliquez-moi</button>
<!-- un signal se lit en l'appelant : compteur(), et non compteur -->
<p class="mt-3 compteur">Vous avez cliqué {{ compteur() }} fois (dernier clic : {{ heure() }}).</p>
</div>
|
- ligne 4 : la syntaxe [{{ expression }}] (l'interpolation) insère dans la page la valeur d'une expression [TypeScript], évaluée sur le composant ([titre] désigne [this.titre]). Elle est échappée : si [titre] contenait [<b>], on verrait [<b>] s'afficher, sans que la balise soit interprétée ;
- ligne 6 : l'expression peut être quelconque : un appel de méthode, un calcul ;
- ligne 9 : un clic sur le bouton appelle la méthode [incrementer] (cf. exemple 04) ;
- ligne 11 : un signal s'affiche en l'appelant : [{{ compteur() }}]. [Angular] note que le template lit ce signal : quand il changera, cet endroit de la page sera mis à jour.
Et son style :
| /* cette règle ne s'applique qu'aux éléments du composant App */
.compteur {
font-weight: bold;
color: #dd0031;
}
|
Cette règle ne s'applique qu'aux éléments de CE composant : [Angular] ajoute un attribut unique à ses éléments ([_ngcontent-xxx]) et le reporte dans les sélecteurs CSS. C'est l'encapsulation des styles : deux composants peuvent utiliser la même classe CSS sans se gêner.
Un composant peut aussi écrire son template et son style directement dans le décorateur, avec les propriétés [template] et [styles] au lieu de [templateUrl] et [styleUrl]. Les exemples suivants le feront pour les petits composants.
2.1.7. Exécution
Dans la console ouverte sur le dossier [exemples-angular] :
npm start 01-premiere-application
La console affiche (extrait) :
| Initial chunk files | Names | Raw size
styles.css | styles | 276.99 kB |
main.js | main | 5.06 kB |
Application bundle generation complete. [1.653 seconds]
Watch mode enabled. Watching for file changes...
➜ Local: http://localhost:4200/
|
On ouvre l'adresse http://localhost:4200/ et l'on clique trois fois sur le bouton :

Deux expériences à faire :
- modifier le texte du titre dans [app.ts] et enregistrer le fichier : la page change aussitôt, sans que l'on ait à la recharger. Le serveur de développement surveille les fichiers, recompile ce qui a changé et prévient le navigateur ;
- afficher le code source de la page (Ctrl-U) : on n'y trouve que la page [index.html], presque vide. Le HTML affiché a été fabriqué par [Angular], dans le navigateur. Pour le voir, il faut l'inspecteur des outils de développement (F12, onglet Éléments), ou l'extension [Angular DevTools], qui montre en plus les composants et leurs données.
2.2. Exemple 02 : les signaux
Les signaux sont le cœur de la réactivité d'[Angular]. Cet exemple calcule le prix TTC d'un article :
| // =========================================================================
// 02-signaux/src/app/app.ts - signal, computed, effect, linkedSignal
// -------------------------------------------------------------------------
// Un petit calculateur de prix TTC :
// - signal : une valeur réactive, qu'on lit en l'appelant et
// qu'on modifie par set() / update() ;
// - computed : une valeur CALCULÉE à partir d'autres signaux,
// recalculée automatiquement quand ils changent ;
// - effect : du code exécuté à chaque changement des signaux qu'il
// lit (un "effet de bord") ;
// - linkedSignal : un signal modifiable, mais REMIS à une valeur calculée
// quand un autre signal change.
// =========================================================================
import { Component, computed, effect, linkedSignal, signal, untracked } from '@angular/core';
// FormsModule apporte [(ngModel)], qui relie un champ à un signal (cf. exemple 05)
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-root',
imports: [FormsModule],
templateUrl: './app.html',
})
export class App {
protected readonly libelle = signal('Stylo');
protected readonly prixHT = signal(2.5);
protected readonly tauxTVA = signal(20); // en %
// linkedSignal : la quantité revient à 1 chaque fois que l'article change,
// mais l'utilisateur peut la modifier entre-temps
protected readonly quantite = linkedSignal({ source: this.libelle, computation: () => 1 });
// computed : jamais affecté directement, toujours recalculé. Angular sait
// de quels signaux il dépend (ceux qu'il LIT) : il le recalcule quand l'un
// d'eux change, et seulement dans ce cas
protected readonly totalHT = computed(() => this.quantite() * this.prixHT());
protected readonly montantTVA = computed(() => (this.totalHT() * this.tauxTVA()) / 100);
protected readonly totalTTC = computed(() => this.totalHT() + this.montantTVA());
// un formateur de montants en euros, à la française (1 234,50 €)
private readonly euros = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' });
// l'historique des totaux TTC, rempli par l'effet ci-dessous
protected readonly historique = signal<string[]>([]);
private ancienTTC: number | null = null;
constructor() {
// effect : exécuté une première fois, puis à chaque changement de
// totalTTC (le seul signal qu'il lit)
effect(() => {
const ttc = this.totalTTC();
document.title = `TTC : ${this.format(ttc)}`;
if (this.ancienTTC !== null) {
const ligne = `${this.format(this.ancienTTC)} -> ${this.format(ttc)}`;
// untracked : modifier historique SANS en faire une dépendance de l'effet
untracked(() => this.historique.update((h) => [ligne, ...h].slice(0, 5)));
}
this.ancienTTC = ttc;
});
}
protected format(montant: number): string {
return this.euros.format(montant);
}
protected reinitialiser(): void {
this.libelle.set('Stylo');
this.prixHT.set(2.5);
this.tauxTVA.set(20);
this.quantite.set(1);
}
}
|
- ligne 15 : les fonctions de réactivité, toutes fournies par [@angular/core] ;
- lignes 21 et 17 : le template utilise [[(ngModel)]], qui vient du module [FormsModule]. Un composant doit importer (tableau [imports]) tout ce que son template utilise : les autres composants, les directives, les pipes. Le compilateur signale une erreur si l'on oublie ;
- lignes 25-27 : trois signaux, [signal(valeur initiale)]. Leur type est déduit de la valeur initiale : [signal('Stylo')] est un signal de chaînes, [signal(2.5)] un signal de nombres ;
- ligne 31 : un [linkedSignal] est un signal modifiable (on peut l'écrire, comme un signal ordinaire), mais lié à un autre signal, sa source : chaque fois que la source change (ici, le libellé de l'article), sa valeur est remise à celle que calcule [computation] (ici, 1). On change d'article : la quantité revient à 1 ;
- lignes 36-38 : un [computed] est une valeur calculée à partir d'autres signaux. On ne l'écrit jamais ; on le lit comme un signal ([this.totalHT()]). [Angular] sait de quels signaux il dépend, parce qu'il note ceux que la fonction lit pendant le calcul. Il est recalculé quand l'un d'eux change, et seulement dans ce cas : lire dix fois [totalTTC()] sans que rien ait changé ne fait qu'un seul calcul. Un [computed] peut dépendre d'autres [computed] (ligne 38) ;
- ligne 41 : un formateur de montants, à la française (1 234,50 €). L'exemple 09 montrera la manière d'[Angular] de le faire, les pipes ;
- lignes 50-59 : un [effect] est une fonction exécutée une première fois, puis à chaque changement des signaux qu'elle lit (ici, le seul [totalTTC]). On s'en sert pour les effets de bord : agir sur autre chose que la page (le titre de l'onglet, ligne 52 ; le stockage local du navigateur ; un journal) ;
- ligne 56 : l'effet ajoute une ligne à l'historique. [untracked(...)] exécute cette mise à jour sans faire de [historique] une dépendance de l'effet : sinon, l'effet lirait [historique] (dans [update]) et se relancerait à chaque changement de l'historique... qu'il provoque lui-même ;
- un [effect] se crée dans le constructeur du composant (ou à l'initialisation d'une propriété) : c'est ce qu'[Angular] appelle le contexte d'injection, dont nous reparlerons avec les services (exemple 14). Il s'arrête tout seul quand le composant disparaît ;
- lignes 66-71 : [set()] remplace la valeur d'un signal. La quantité est un [linkedSignal] : on peut l'écrire comme les autres.
On évite d'écrire dans un signal depuis un [effect] quand un [computed] suffit : un [computed] décrit une valeur, un [effect] une action. Ici, l'historique dépend des valeurs successives du total, ce qu'un [computed] ne sait pas exprimer.
Le template :
| <div class="container py-4">
<h1 class="h3 mb-3">Exemple 02 - les signaux</h1>
<div class="row g-4">
<div class="col-md-6">
<div class="card p-3">
<!-- [(ngModel)]="signal" : le champ lit le signal et le modifie (cf. exemple 05) -->
<label class="form-label" for="libelle">Article</label>
<input id="libelle" class="form-control mb-2" [(ngModel)]="libelle">
<label class="form-label" for="prix">Prix unitaire HT</label>
<input id="prix" type="number" step="0.1" min="0" class="form-control mb-2" [(ngModel)]="prixHT">
<label class="form-label" for="quantite">Quantité (revient à 1 si l'article change)</label>
<input id="quantite" type="number" min="0" class="form-control mb-2" [(ngModel)]="quantite">
<label class="form-label" for="tva">Taux de TVA</label>
<select id="tva" class="form-select mb-3" [(ngModel)]="tauxTVA">
<!-- [ngValue] : la valeur transmise est le NOMBRE 5.5, pas la chaîne "5.5" -->
<option [ngValue]="5.5">5,5 %</option>
<option [ngValue]="10">10 %</option>
<option [ngValue]="20">20 %</option>
</select>
<button class="btn btn-outline-secondary" (click)="reinitialiser()">Réinitialiser</button>
</div>
</div>
<div class="col-md-6">
<div class="card p-3">
<h2 class="h5">{{ quantite() }} x {{ libelle() }}</h2>
<table class="table mb-0">
<tbody>
<tr><td>Total HT</td><td class="text-end">{{ format(totalHT()) }}</td></tr>
<tr><td>TVA ({{ tauxTVA() }} %)</td><td class="text-end">{{ format(montantTVA()) }}</td></tr>
<tr class="fw-bold"><td>Total TTC</td><td class="text-end">{{ format(totalTTC()) }}</td></tr>
</tbody>
</table>
</div>
<div class="card p-3 mt-3">
<h2 class="h6">Historique du total TTC (effect)</h2>
<ul class="mb-0">
@for (ligne of historique(); track $index) {
<li>{{ ligne }}</li>
}
</ul>
</div>
</div>
</div>
</div>
|
- lignes 8-18 : [[(ngModel)]="libelle"] relie le champ au signal [libelle], dans les deux sens : taper dans le champ modifie le signal, et modifier le signal (bouton « Réinitialiser ») modifie le champ. On donne le signal lui-même, sans parenthèses : [ngModel] a besoin du signal pour pouvoir l'écrire. L'exemple 05 présentera [ngModel] en détail ;
- lignes 10 et 12 : avec [type="number"], le signal reçoit un nombre, et non une chaîne ;
- lignes 16-18 : [[ngValue]] donne la valeur d'une option : ici le nombre 5.5, et non la chaîne ["5.5"] ;
- lignes 25-30 : l'affichage des signaux et des [computed]. [format(...)] est une méthode du composant ;
- lignes 37-39 : [@for] répète le bloc pour chaque élément d'une liste (cf. exemple 03).
Le serveur de développement étant lancé ([npm start 02-signaux]), on modifie l'article, le prix, la quantité et le taux :

À chaque changement du total TTC, l'effet ajoute une ligne à l'historique et change le titre de l'onglet. Changer l'article remet la quantité à 1.
Les signaux ne sont pas propres à [Angular] : on les retrouve dans [SolidJS], [Preact], [Vue.js] (où les [ref] jouent le même rôle), et un projet de norme veut les ajouter à [JavaScript] lui-même. Avant les signaux (introduits dans [Angular] 16, en 2023), [Angular] vérifiait régulièrement tout l'arbre des composants, à l'aide de la bibliothèque [zone.js], pour découvrir ce qui avait changé. Avec les signaux, il sait exactement quoi mettre à jour : c'est le mode zoneless, celui d'[Angular] 22 par défaut, et celui de tous les exemples de ce cours.
2.3. Exemple 03 : la syntaxe des templates
Un template [Angular] est du HTML, enrichi de quelques syntaxes. Cet exemple affiche une liste de médecins, qu'on peut filtrer :
| // =========================================================================
// 03-templates/src/app/app.ts - la syntaxe des templates
// -------------------------------------------------------------------------
// Dans un template Angular :
// - [propriete]="expr" : une propriété de l'élément, calculée par une
// expression ([src], [disabled], [hidden]...) ;
// - [class.nom]="expr" : la classe CSS "nom" si l'expression est vraie ;
// [style.prop]="expr" : une propriété CSS ;
// - @if / @else if / @else : un bloc qui n'existe que si la condition
// est vraie ;
// - @for (x of liste; track x.id) : un bloc répété pour chaque élément ;
// track donne l'IDENTITÉ de chaque élément ; @empty, le bloc affiché
// quand la liste est vide ;
// - @switch / @case : un bloc parmi plusieurs, selon une valeur ;
// - @let nom = expr; : une variable locale au template.
// =========================================================================
import { Component, computed, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
// une interface TypeScript décrit la forme des objets de la liste
interface Medecin {
id: number;
titre: string;
prenom: string;
nom: string;
specialite: 'généraliste' | 'pédiatre' | 'cardiologue';
disponible: boolean;
}
@Component({
selector: 'app-root',
imports: [FormsModule],
templateUrl: './app.html',
})
export class App {
// l'image est dans le dossier public/ de l'exemple : servie telle quelle
protected readonly urlLogo = 'medecin.svg';
protected readonly medecins = signal<Medecin[]>([
{ id: 1, titre: 'Mme', prenom: 'Marie', nom: 'PELISSIER', specialite: 'généraliste', disponible: true },
{ id: 2, titre: 'Mr', prenom: 'Paul', nom: 'TATOU', specialite: 'pédiatre', disponible: false },
{ id: 3, titre: 'Melle', prenom: 'Sophie', nom: 'DUPONT', specialite: 'cardiologue', disponible: true },
]);
protected readonly filtre = signal(''); // texte recherché
protected readonly seulementDisponibles = signal(false);
protected readonly afficherAide = signal(true);
// la liste affichée : un computed, recalculé quand le filtre change
protected readonly medecinsAffiches = computed(() => {
const texte = this.filtre().trim().toLowerCase();
return this.medecins().filter(
(m) =>
(!this.seulementDisponibles() || m.disponible) &&
`${m.prenom} ${m.nom} ${m.specialite}`.toLowerCase().includes(texte),
);
});
protected basculerAide(): void {
this.afficherAide.update((a) => !a);
}
}
|
- lignes 22-29 : une interface [TypeScript] décrit la forme des objets de la liste. La spécialité ne peut prendre que trois valeurs (ligne 27) ;
- ligne 38 : l'adresse d'une image. Les fichiers du dossier [public/] de l'exemple sont servis tels quels, à la racine du site : [medecin.svg] désigne le fichier [03-templates/public/medecin.svg]. Il faut pour cela le déclarer dans [angular.json] :
| "assets": [
{
"glob": "**/*",
"input": "03-templates/public"
}
]
|
- lignes 40-44 : [signal<Medecin[]>(...)] : un signal dont on précise le type, une liste de médecins ;
- lignes 46-48 : trois signaux reliés aux champs de la page ;
- lignes 51-58 : la liste affichée, un [computed] qui dépend de quatre signaux ([filtre], [medecins], [seulementDisponibles]) ;
- ligne 61 : [update] inverse un booléen.
Le template :
| <div class="container py-4">
<h1 class="h3 mb-3">
<!-- [src] : la valeur de la propriété src est celle de urlLogo -->
<img [src]="urlLogo" alt="logo" width="32" height="32">
Exemple 03 - la syntaxe des templates
</h1>
<div class="d-flex flex-wrap gap-3 align-items-center mb-3">
<input class="form-control w-auto" placeholder="rechercher..." [(ngModel)]="filtre">
<label class="form-check-label">
<input type="checkbox" class="form-check-input" [(ngModel)]="seulementDisponibles"> disponibles seulement
</label>
<button class="btn btn-sm btn-outline-secondary" (click)="basculerAide()">
{{ afficherAide() ? "Cacher l'aide" : "Montrer l'aide" }}
</button>
</div>
<!-- [hidden] : le paragraphe reste dans la page, simplement caché -->
<p class="alert alert-info" [hidden]="!afficherAide()">
Tapez un nom ou une spécialité. Les médecins indisponibles sont grisés.
</p>
<!-- @let : une variable du template, pour ne pas répéter l'appel -->
@let affiches = medecinsAffiches();
<!-- @if / @else if / @else : un seul des trois blocs existe à la fois -->
@if (medecins().length === 0) {
<p class="text-muted">Aucun médecin.</p>
} @else if (affiches.length === 0) {
<p class="text-warning">Aucun médecin ne correspond à « {{ filtre() }} ».</p>
} @else {
<table class="table">
<thead>
<tr><th>#</th><th>Médecin</th><th>Spécialité</th><th>Statut</th></tr>
</thead>
<tbody>
<!-- @for : une ligne par médecin ; $index donne le rang.
track m.id : l'identité de chaque ligne, pour qu'Angular sache
quelle ligne ajouter, déplacer ou supprimer quand la liste change -->
@for (m of affiches; track m.id; let i = $index) {
<tr [class.opacity-50]="!m.disponible">
<td>{{ i + 1 }}</td>
<td>{{ m.titre }} {{ m.prenom }} {{ m.nom }}</td>
<td>
<!-- @switch : un bloc selon la valeur de la spécialité -->
@switch (m.specialite) {
@case ('pédiatre') { <span title="enfants">🧸</span> }
@case ('cardiologue') { <span title="cœur">❤️</span> }
@default { <span title="tous publics">🩺</span> }
}
{{ m.specialite }}
</td>
<td>
<!-- [class] avec une chaîne : les classes calculées s'ajoutent à "badge" -->
<span class="badge" [class]="m.disponible ? 'text-bg-success' : 'text-bg-secondary'">
{{ m.disponible ? 'disponible' : 'indisponible' }}
</span>
</td>
</tr>
}
</tbody>
</table>
}
<!-- [style.font-size.px] : une propriété CSS, avec son unité -->
<p [style.font-size.px]="12 + affiches.length * 2">
{{ affiches.length }} médecin(s) affiché(s) - la taille de ce texte en dépend.
</p>
</div>
|
- ligne 4 : [[src]="urlLogo"] est une liaison de propriété : la propriété [src] de l'élément reçoit la valeur de l'expression [urlLogo]. Sans crochets, [src="urlLogo"] donnerait la chaîne ["urlLogo"]. On peut lier ainsi n'importe quelle propriété d'un élément : [[disabled]], [[value]], [[hidden]], [[title]]... ;
- lignes 9 et 11 : deux champs reliés à des signaux par [[(ngModel)]] (cf. exemple 05) ;
- ligne 14 : l'interpolation accepte l'opérateur ternaire [? :] ;
- ligne 19 : [[hidden]] : le paragraphe reste dans la page, simplement caché (propriété HTML [hidden]) ;
- ligne 24 : [@let] déclare une variable du template, visible dans la suite du template. On évite ainsi de répéter [medecinsAffiches()] ;
- lignes 27-63 : [@if (condition) { ... } @else if (...) { ... } @else { ... }] : un seul des trois blocs existe dans la page à la fois. À la différence de [[hidden]], les éléments d'un bloc non retenu ne sont pas créés (ou sont supprimés) ;
- ligne 40 : [@for (m of affiches; track m.id)] répète le bloc pour chaque médecin :
- [track m.id] est obligatoire : c'est l'identité de chaque ligne. Quand la liste change, [Angular] compare les identités pour savoir quelle ligne ajouter, déplacer ou supprimer, au lieu de tout refaire. Pour une liste de valeurs simples sans identifiant, on écrit [track $index] (exemple 02) ;
- [let i = $index] : le rang de l'élément. [@for] fournit aussi [$first], [$last], [$even], [$odd] et [$count] ;
- un bloc [@empty { ... }], après le bloc de [@for], s'affiche quand la liste est vide (exemple 16) ;
- ligne 41 : [[class.opacity-50]="condition"] : la classe [opacity-50] (de [Bootstrap]) est posée sur l'élément si la condition est vraie ;
- lignes 46-50 : [@switch] choisit un bloc selon une valeur : un [@case] par valeur, et [@default] pour les autres ;
- ligne 55 : [[class]="expression"] : l'expression donne une ou plusieurs classes, qui s'ajoutent aux classes écrites en dur ([badge]) ;
- ligne 66 : [[style.font-size.px]="expression"] : une propriété CSS, avec son unité.
Une expression de template ressemble à du [TypeScript], mais elle est limitée : pas d'affectation (sauf dans les gestionnaires d'événements, exemple 04), pas de [new], pas d'accès aux variables globales ([window], [document]). Elle ne voit que les propriétés et méthodes du composant.
[npm start 03-templates] :

On tape « gén » et l'on cache l'aide :

Les anciennes versions d'[Angular] écrivaient ces blocs avec des directives structurelles : [*ngIf], [*ngFor], [[ngSwitch]]. On les rencontre encore dans beaucoup de projets ; la syntaxe [@if] / [@for] / [@switch], apparue avec [Angular] 17, est aujourd'hui recommandée.
2.4. Exemple 04 : les événements
Une liste de tâches, pour réagir aux actions de l'utilisateur. Le composant :
| // =========================================================================
// 04-evenements/src/app/app.ts - réagir aux actions de l'utilisateur
// -------------------------------------------------------------------------
// (evenement)="instruction" : l'instruction est exécutée quand l'élément
// émet cet événement :
// - (click)="f()" appelle f ;
// - (click)="f(3, $event)" appelle f avec des arguments ; $event est
// l'événement natif du navigateur ;
// - (keyup.enter), (keydown.escape) : seulement pour cette touche ;
// - #nom sur un élément : une "variable de référence" du template, qui
// désigne l'élément HTML lui-même (nom.value, nom.focus()...).
// Ici, pas de FormsModule ni de ngModel : on lit les champs nous-mêmes.
// =========================================================================
import { Component, signal } from '@angular/core';
interface Tache {
id: number;
libelle: string;
faite: boolean;
}
@Component({
selector: 'app-root',
templateUrl: './app.html',
styles: `
.zone {
background: #e9f2ff;
height: 80px;
cursor: crosshair;
}
`,
})
export class App {
protected readonly taches = signal<Tache[]>([
{ id: 1, libelle: 'Installer Node.js', faite: true },
{ id: 2, libelle: 'Lire le chapitre sur les événements', faite: false },
]);
protected readonly journal = signal<string[]>([]);
private prochainId = 3;
private tracer(message: string): void {
this.journal.update((j) => [`${new Date().toLocaleTimeString()} ${message}`, ...j]);
}
// appelée par (submit) : $event.preventDefault() empêche le navigateur
// d'envoyer le formulaire au serveur (et de recharger la page)
protected ajouter(evenement: SubmitEvent, champ: HTMLInputElement): void {
evenement.preventDefault();
const libelle = champ.value.trim();
if (libelle === '') {
return;
}
// un signal contenant un tableau : on le REMPLACE par un nouveau tableau
this.taches.update((t) => [...t, { id: this.prochainId++, libelle, faite: false }]);
this.tracer(`ajout de « ${libelle} »`);
champ.value = '';
champ.focus();
}
protected basculer(tache: Tache, faite: boolean): void {
this.taches.update((t) => t.map((x) => (x.id === tache.id ? { ...x, faite } : x)));
this.tracer(`« ${tache.libelle} » : ${faite ? 'faite' : 'à faire'}`);
}
protected supprimer(tache: Tache): void {
this.taches.update((t) => t.filter((x) => x.id !== tache.id));
this.tracer(`suppression de « ${tache.libelle} »`);
}
// $event : l'événement natif (ici un MouseEvent), avec ses coordonnées
protected position(evenement: MouseEvent): void {
this.tracer(`clic en (${evenement.offsetX}, ${evenement.offsetY})`);
}
}
|
- lignes 26-32 : le style du composant, écrit directement dans le décorateur ([styles]), plutôt que dans un fichier [.css] ;
- lignes 35-38 : un signal qui contient une liste d'objets ;
- ligne 40 : une propriété ordinaire, qui n'est pas affichée : elle n'a pas besoin d'être un signal ;
- lignes 48-59 : l'ajout d'une tâche. La méthode reçoit l'événement du navigateur et l'élément [<input>] lui-même (cf. le template) :
- ligne 49 : [preventDefault()] empêche le navigateur d'envoyer le formulaire au serveur, ce qui rechargerait la page ;
- ligne 55 : on ne modifie pas le tableau contenu dans le signal ([push]) : on le remplace par un nouveau tableau. Un signal ne signale un changement que si sa valeur change, c'est-à-dire si l'on y range un AUTRE objet. Modifier l'intérieur du tableau, sans le remplacer, laisserait la page en l'état ;
- lignes 57-58 : le champ est vidé, et garde le focus ;
- ligne 62 : même principe pour modifier une tâche : [map] fabrique un nouveau tableau, où la tâche concernée est remplacée par une copie modifiée ([{ ...x, faite }]) ;
- ligne 67 : et pour en supprimer une, [filter] ;
- lignes 72-74 : [MouseEvent] : l'événement d'un clic, avec la position du pointeur.
Le template :
| <div class="container py-4">
<h1 class="h3 mb-3">Exemple 04 - les événements</h1>
<!-- (submit) : Entrée dans le champ OU clic sur le bouton.
#nouvelle : une référence à l'élément <input>, utilisable partout
dans le template -->
<form class="d-flex gap-2 mb-3" (submit)="ajouter($event, nouvelle)">
<input #nouvelle class="form-control" placeholder="nouvelle tâche (Échap pour vider)"
(keydown.escape)="nouvelle.value = ''">
<button type="submit" class="btn btn-primary">Ajouter</button>
</form>
<ul class="list-group mb-3">
@for (t of taches(); track t.id) {
<li class="list-group-item d-flex align-items-center gap-2">
<!-- #case : la case à cocher de CETTE ligne ; case.checked : son état -->
<input #case type="checkbox" class="form-check-input" [checked]="t.faite"
(change)="basculer(t, case.checked)">
<span class="flex-grow-1" [class.text-decoration-line-through]="t.faite" [class.text-muted]="t.faite">
{{ t.libelle }}
</span>
<!-- un gestionnaire avec argument : la tâche de CETTE ligne -->
<button class="btn btn-sm btn-outline-danger" (click)="supprimer(t)">Supprimer</button>
</li>
}
</ul>
<div class="zone p-3 mb-3 rounded" (click)="position($event)">Cliquez dans cette zone</div>
<h2 class="h6">Journal</h2>
<ul class="small">
@for (ligne of journal(); track $index) {
<li>{{ ligne }}</li>
}
</ul>
</div>
|
- ligne 7 : [(submit)="instruction"] est une liaison d'événement : l'instruction est exécutée quand l'élément émet l'événement [submit] (Entrée dans le champ, ou clic sur le bouton). [$event] désigne l'événement du navigateur ;
- ligne 8 : [#nouvelle] est une variable de référence du template : elle désigne l'élément [<input>] lui-même, dans tout le template. Ligne 7, on la passe à la méthode [ajouter] ;
- ligne 9 : [(keydown.escape)] : l'événement [keydown], mais seulement pour la touche Échap. On écrit de même [(keyup.enter)], [(keydown.control.s)]... Dans une liaison d'événement, l'affectation est permise : [nouvelle.value = ''] vide le champ ;
- lignes 17-18 : [#case] désigne la case à cocher de CETTE ligne du [@for] ; [[checked]] coche la case selon la tâche, [(change)] prévient le composant quand l'utilisateur la change ;
- ligne 23 : un gestionnaire avec argument : la tâche de cette ligne ;
- ligne 28 : un clic dans la zone : [$event] est un [MouseEvent].
Ici, on lit les champs soi-même ([champ.value], [case.checked]). Les exemples suivants confieront ce travail à [ngModel] puis aux formulaires à signaux.
[npm start 04-evenements] ; on ajoute une tâche (Entrée), on coche la seconde, on clique dans la zone :

[Angular] ne met à jour la page qu'en réponse à un événement ou au changement d'un signal. Un événement traité par le template ((click), (submit)...) suffit à faire vérifier le composant ; mais une donnée modifiée plus tard, en dehors d'un événement (à la réponse d'un serveur, au tic d'une minuterie), ne sera affichée que si c'est un signal. D'où la règle suivie dans tout ce cours : toute donnée affichée par un template est un signal.
Le même formulaire que l'exemple 18 du cours [NestJS] (tous les types de champs), mais sans serveur : tout se passe dans le navigateur. Les champs sont reliés à des signaux par la directive [ngModel] :
| // =========================================================================
// 05-ngmodel/src/app/app.ts - tous les types de champs, reliés par ngModel
// -------------------------------------------------------------------------
// [(ngModel)]="signal" (module FormsModule) relie un champ de formulaire à un
// signal, dans les DEUX sens : taper dans le champ modifie le signal, et
// modifier le signal (par le code) modifie le champ. La syntaxe [( )], dite
// "banane dans une boîte", combine [ngModel] (le signal vers le champ) et
// (ngModelChange) (le champ vers le signal). La valeur reçue dépend du champ :
// - input texte, textarea -> string
// - input type="number" -> number (null si le champ est vide)
// - case à cocher seule -> boolean
// - boutons radio -> la valeur du bouton choisi
// - select / select multiple -> la valeur / un tableau de valeurs
// - input type="date" -> string "yyyy-MM-dd"
// Dans un <form>, chaque champ relié par ngModel doit avoir un attribut name.
// Même formulaire que l'exemple 18 du cours NestJS ; mais ici, pas de
// serveur : tout se passe dans le navigateur.
// =========================================================================
import { Component, computed, signal } from '@angular/core';
import { JsonPipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-root',
// JsonPipe : le "pipe" json, qui affiche un objet au format JSON (cf. exemple 09)
imports: [FormsModule, JsonPipe],
templateUrl: './app.html',
})
export class App {
protected readonly titres = ['Mr', 'Mme', 'Melle'];
protected readonly loisirsPossibles = ['sport', 'musique', 'lecture', 'cinéma'];
protected readonly listePays = [
{ code: 'fr', libelle: 'France' },
{ code: 'be', libelle: 'Belgique' },
{ code: 'ch', libelle: 'Suisse' },
];
// un signal par champ du formulaire
protected readonly titre = signal('Mme');
protected readonly nom = signal('');
protected readonly age = signal<number | null>(30);
protected readonly presentation = signal('');
protected readonly newsletter = signal(false);
protected readonly loisirs = signal<string[]>(['lecture']);
protected readonly pays = signal('fr');
protected readonly langues = signal<string[]>([]);
protected readonly naissance = signal('');
// toutes les valeurs, réunies en un objet (ce qu'on enverrait à un serveur)
protected readonly modele = computed(() => ({
titre: this.titre(),
nom: this.nom().trim(), // ngModel ne retire pas les espaces : on le fait ici
age: this.age(),
presentation: this.presentation(),
newsletter: this.newsletter(),
loisirs: this.loisirs(),
pays: this.pays(),
langues: this.langues(),
naissance: this.naissance(),
}));
protected readonly envoye = signal<string | null>(null);
// plusieurs cases pour UN tableau : ngModel ne sait pas faire, on s'en charge
protected basculerLoisir(loisir: string, coche: boolean): void {
this.loisirs.update((l) => (coche ? [...l, loisir] : l.filter((x) => x !== loisir)));
}
// (ngSubmit) : l'envoi du formulaire, sans rechargement de la page
protected envoyer(): void {
this.envoye.set(JSON.stringify(this.modele()));
}
}
|
- ligne 22 : [FormsModule] apporte la directive [ngModel] (et [ngSubmit], [ngModelOptions]...) : ce sont les formulaires dits « pilotés par le template » ([template-driven forms]) ;
- ligne 27 : le composant importe aussi le pipe [json], qui affiche un objet au format JSON (cf. exemple 09) ;
- lignes 40-48 : un signal par champ, avec le type de valeur que le champ fournira : une chaîne, un nombre (ou [null] si le champ est vidé), un booléen, une liste de chaînes ;
- lignes 51-61 : toutes les valeurs, réunies en un objet par un [computed] : c'est ce qu'on enverrait à un serveur. [ngModel] ne retire pas les espaces autour d'une saisie : on le fait ici (ligne 53) ;
- lignes 66-68 : plusieurs cases à cocher pour une même liste : [ngModel] ne sait pas le faire, on s'en charge ;
- lignes 71-73 : l'envoi.
Le template :
| <div class="container py-4">
<h1 class="h3 mb-3">Exemple 05 - les champs d'un formulaire et ngModel</h1>
<div class="row g-4">
<!-- (ngSubmit) : avec FormsModule, le formulaire n'est jamais envoyé au
serveur par le navigateur ; c'est notre méthode qui est appelée -->
<form class="col-md-7" (ngSubmit)="envoyer()">
<div class="mb-3">
<span class="form-label d-block">Titre</span>
<!-- boutons radio : même ngModel, une valeur par bouton -->
@for (t of titres; track t) {
<div class="form-check form-check-inline">
<input type="radio" class="form-check-input" name="titre" [id]="'titre-' + t" [value]="t"
[(ngModel)]="titre">
<label class="form-check-label" [for]="'titre-' + t">{{ t }}</label>
</div>
}
</div>
<div class="row g-3 mb-3">
<div class="col-8">
<label class="form-label" for="nom">Nom</label>
<input id="nom" name="nom" class="form-control" [(ngModel)]="nom">
</div>
<div class="col-4">
<label class="form-label" for="age">Âge</label>
<!-- type="number" : le signal reçoit un nombre, pas la chaîne "30" -->
<input id="age" name="age" type="number" class="form-control" [(ngModel)]="age">
</div>
</div>
<div class="mb-3">
<label class="form-label" for="presentation">Présentation (mise à jour à la sortie du champ)</label>
<!-- updateOn: 'blur' : le signal n'est mis à jour qu'à la sortie du champ -->
<textarea id="presentation" name="presentation" rows="2" class="form-control"
[(ngModel)]="presentation" [ngModelOptions]="{ updateOn: 'blur' }"></textarea>
</div>
<div class="mb-3 form-check">
<!-- une case seule : true / false -->
<input id="newsletter" name="newsletter" type="checkbox" class="form-check-input" [(ngModel)]="newsletter">
<label for="newsletter" class="form-check-label">Recevoir la lettre d'information</label>
</div>
<div class="mb-3">
<span class="form-label d-block">Loisirs</span>
<!-- plusieurs cases pour un même tableau : [checked] + (change) -->
@for (l of loisirsPossibles; track l) {
<div class="form-check form-check-inline">
<input #caseLoisir type="checkbox" class="form-check-input" [id]="'loisir-' + l"
[checked]="loisirs().includes(l)" (change)="basculerLoisir(l, caseLoisir.checked)">
<label class="form-check-label" [for]="'loisir-' + l">{{ l }}</label>
</div>
}
</div>
<div class="row g-3 mb-3">
<div class="col-6">
<label class="form-label" for="pays">Pays</label>
<select id="pays" name="pays" class="form-select" [(ngModel)]="pays">
<!-- [value] : la valeur transmise (le code), le texte affiché étant le libellé -->
@for (p of listePays; track p.code) {
<option [value]="p.code">{{ p.libelle }}</option>
}
</select>
</div>
<div class="col-6">
<label class="form-label" for="langues">Langues parlées</label>
<select id="langues" name="langues" multiple size="3" class="form-select" [(ngModel)]="langues">
<option value="fr">français</option>
<option value="en">anglais</option>
<option value="es">espagnol</option>
</select>
</div>
</div>
<div class="mb-3">
<label class="form-label" for="naissance">Date de naissance</label>
<input id="naissance" name="naissance" type="date" class="form-control w-auto" [(ngModel)]="naissance">
</div>
<button type="submit" class="btn btn-primary">Envoyer</button>
</form>
<div class="col-md-5">
<h2 class="h6">Le modèle, en direct</h2>
<pre class="bg-light p-2 rounded small">{{ modele() | json }}</pre>
@if (envoye(); as texte) {
<h2 class="h6">Dernier envoi</h2>
<pre class="bg-success-subtle p-2 rounded small">{{ texte }}</pre>
}
</div>
</div>
</div>
|
- ligne 6 : [(ngSubmit)] : avec [FormsModule], le formulaire n'est jamais envoyé par le navigateur (pas de rechargement) ; c'est la méthode [envoyer] qui est appelée. Plus besoin de [preventDefault()] (cf. exemple 04) ;
- lignes 12-13 : des boutons radio : le même [ngModel] sur chaque bouton, et une valeur par bouton ([[value]]). Le signal reçoit la valeur du bouton coché ;
- ligne 21 : [[(ngModel)]="nom"] relie le champ au signal [nom], dans les deux sens. La syntaxe [[( )]], dite « banane dans une boîte », combine une liaison de propriété [[ngModel]] (du signal vers le champ) et une liaison d'événement [(ngModelChange)] (du champ vers le signal). Dans un [<form>], chaque champ relié par [ngModel] doit avoir un attribut [name] ;
- ligne 26 : avec [type="number"], le signal reçoit un nombre ;
- ligne 33 : [[ngModelOptions]="{ updateOn: 'blur' }"] : le signal n'est mis à jour qu'à la sortie du champ, et non à chaque frappe ;
- ligne 37 : une case seule : [true] / [false] ;
- lignes 45-46 : les cases des loisirs, gérées à la main : [[checked]] et [(change)], comme à l'exemple 04 ;
- lignes 54-59 : une liste déroulante : le signal reçoit la valeur ([[value]], le code du pays) de l'option choisie ;
- ligne 63 : une liste à choix multiples ([multiple]) : le signal reçoit la liste des valeurs choisies ;
- ligne 72 : une date : le signal reçoit une chaîne ["yyyy-MM-dd"] ;
- ligne 79 : [{{ modele() | json }}] : le modèle, au format JSON, en direct ;
- ligne 80 : [@if (envoye(); as texte)] : si la valeur est « vraie » (ici, une chaîne non vide), le bloc s'affiche, et [as texte] en fait une variable du bloc.
[npm start 05-ngmodel] ; on remplit le formulaire, puis on l'envoie :

[ngModel] existe depuis la première version d'[Angular], et accepte des signaux depuis [Angular] 17. On le rencontre dans d'innombrables applications. Mais il laisse à la charge du composant tout ce qui dépasse le simple va-et-vient d'une valeur : réunir les champs, savoir lesquels ont été modifiés, les valider... L'exemple suivant présente la manière la plus récente d'écrire un formulaire.
[Angular] 22 apporte une nouvelle manière d'écrire les formulaires, entièrement fondée sur les signaux : les formulaires à signaux ([Signal Forms], paquet [@angular/forms/signals]). Le principe :
- le modèle du formulaire est UN signal, qui contient un objet avec une propriété par champ ;
- la fonction [form(modele)] fabrique un arbre de champs ([FieldTree]) qui a la même forme que le modèle : [f.nom], [f.age], [f.newsletter]... ;
- dans le template, [[formField]="f.nom"] relie un champ HTML à un champ de l'arbre.
Le formulaire lit et écrit dans le modèle ; et chaque champ connaît son état : a-t-il été touché, modifié, est-il désactivé, valide ?
| // =========================================================================
// 06-formulaires-signaux/src/app/app.ts - les formulaires à signaux
// -------------------------------------------------------------------------
// La manière moderne (Angular 22) d'écrire un formulaire :
// 1. le MODÈLE : un signal qui contient un objet, avec une propriété par
// champ (le formulaire lit et écrit dans ce signal) ;
// 2. form(modele, schema) : fabrique un ARBRE DE CHAMPS (FieldTree), qui
// a la même forme que le modèle : f.nom, f.age, f.newsletter... Le
// schéma (facultatif) décrit les règles des champs (cf. exemple 07) ;
// 3. [formField]="f.nom" relie un champ HTML à un champ de l'arbre.
// Chaque champ a un ÉTAT, obtenu en l'appelant : f.nom() donne sa valeur
// (value()), s'il a été touché (touched()), modifié (dirty()), s'il est
// désactivé (disabled())...
// =========================================================================
import { Component, signal } from '@angular/core';
import { JsonPipe } from '@angular/common';
import { disabled, form, FormField } from '@angular/forms/signals';
// la forme des données du formulaire
interface Profil {
titre: string;
prenom: string;
nom: string;
age: number;
newsletter: boolean;
email: string;
pays: string;
naissance: string;
presentation: string;
}
const DEPART: Profil = {
titre: 'Mme',
prenom: 'Marie',
nom: 'PELISSIER',
age: 30,
newsletter: false,
email: '',
pays: 'fr',
naissance: '',
presentation: '',
};
@Component({
selector: 'app-root',
imports: [FormField, JsonPipe],
templateUrl: './app.html',
})
export class App {
protected readonly titres = ['Mr', 'Mme', 'Melle'];
// 1. le modèle
protected readonly modele = signal<Profil>({ ...DEPART });
// 2. l'arbre des champs ; une règle : l'e-mail n'est modifiable que si la
// case "newsletter" est cochée (valueOf lit la valeur d'un AUTRE champ)
protected readonly f = form(this.modele, (p) => {
disabled(p.email, ({ valueOf }) => !valueOf(p.newsletter));
});
// les champs dont on affiche l'état, dans le tableau de droite
protected readonly champs = ['prenom', 'nom', 'age', 'email'] as const;
protected reinitialiser(): void {
// on remet le modèle à sa valeur de départ, et l'état des champs à zéro
this.modele.set({ ...DEPART });
this.f().reset();
}
protected majuscules(): void {
// modifier le MODÈLE par le code : les champs suivent
this.modele.update((m) => ({ ...m, nom: m.nom.toUpperCase(), prenom: m.prenom.toUpperCase() }));
}
}
|
- ligne 18 : les fonctions et la directive des formulaires à signaux ;
- lignes 21-31 : la forme des données du formulaire ;
- ligne 47 : le composant importe la directive [FormField], celle de [[formField]] ;
- ligne 54 : le modèle : un signal qui contient un objet [Profil] ;
- lignes 58-60 : l'arbre des champs. Le second argument de [form] est le schéma du formulaire : une fonction qui reçoit les chemins des champs ([p.email], [p.newsletter]...) et y attache des règles. Ici, une seule : [disabled(p.email, logique)] désactive le champ [email] quand la fonction [logique] renvoie [true]. [valueOf(p.newsletter)] lit la valeur d'un autre champ : l'e-mail n'est modifiable que si la case « newsletter » est cochée. La règle est réactive : elle est réévaluée dès que la case change. L'exemple 07 ajoutera des règles de validation ;
- ligne 63 : les noms des champs dont le template affiche l'état ; [as const] en fait une liste de noms exacts (['prenom' | 'nom' | ...]), et non de chaînes quelconques ;
- lignes 65-69 : la réinitialisation. On remet le modèle à sa valeur de départ (les champs suivent), puis on remet à zéro l'état des champs ([touched], [dirty]) : [f()] (l'arbre, appelé) donne l'état du formulaire entier, dont la méthode [reset()] ;
- lignes 71-74 : modifier le modèle par le code : les champs de la page suivent.
Le template :
| <div class="container py-4">
<h1 class="h3 mb-3">Exemple 06 - les formulaires à signaux</h1>
<div class="row g-4">
<form class="col-md-7" (submit)="$event.preventDefault()">
<div class="mb-3">
<span class="form-label d-block">Titre</span>
<!-- boutons radio : le même champ, une valeur par bouton -->
@for (t of titres; track t) {
<div class="form-check form-check-inline">
<input type="radio" class="form-check-input" [id]="'titre-' + t" [value]="t" [formField]="f.titre">
<label class="form-check-label" [for]="'titre-' + t">{{ t }}</label>
</div>
}
</div>
<div class="row g-3 mb-3">
<div class="col-5">
<label class="form-label" for="prenom">Prénom</label>
<!-- [formField] : ce champ HTML édite le champ f.prenom du formulaire -->
<input id="prenom" class="form-control" [formField]="f.prenom">
</div>
<div class="col-5">
<label class="form-label" for="nom">Nom</label>
<input id="nom" class="form-control" [formField]="f.nom">
</div>
<div class="col-2">
<label class="form-label" for="age">Âge</label>
<!-- type="number" et un modèle de type number : une valeur numérique -->
<input id="age" type="number" class="form-control" [formField]="f.age">
</div>
</div>
<div class="row g-3 mb-3 align-items-end">
<div class="col-5">
<div class="form-check">
<input id="newsletter" type="checkbox" class="form-check-input" [formField]="f.newsletter">
<label for="newsletter" class="form-check-label">Recevoir la lettre d'information</label>
</div>
</div>
<div class="col-7">
<label class="form-label" for="email">E-mail (si lettre d'information)</label>
<!-- disabled est géré par le formulaire : pas d'attribut à écrire ici -->
<input id="email" type="email" class="form-control" [formField]="f.email">
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-6">
<label class="form-label" for="pays">Pays</label>
<select id="pays" class="form-select" [formField]="f.pays">
<option value="fr">France</option>
<option value="be">Belgique</option>
<option value="ch">Suisse</option>
</select>
</div>
<div class="col-6">
<label class="form-label" for="naissance">Date de naissance</label>
<input id="naissance" type="date" class="form-control" [formField]="f.naissance">
</div>
</div>
<div class="mb-3">
<label class="form-label" for="presentation">Présentation</label>
<textarea id="presentation" rows="2" class="form-control" [formField]="f.presentation"></textarea>
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-primary" (click)="majuscules()">Nom en majuscules</button>
<button type="button" class="btn btn-outline-secondary" (click)="reinitialiser()">Réinitialiser</button>
</div>
</form>
<div class="col-md-5">
<h2 class="h6">Le modèle, en direct</h2>
<!-- f().value() : la valeur de tout le formulaire, c'est-à-dire le modèle -->
<pre class="bg-light p-2 rounded small">{{ f().value() | json }}</pre>
<h2 class="h6">L'état des champs</h2>
<table class="table table-sm small">
<thead><tr><th>champ</th><th>touched</th><th>dirty</th><th>disabled</th></tr></thead>
<tbody>
@for (c of champs; track c) {
<tr>
<td>{{ c }}</td>
<td>{{ f[c]().touched() }}</td>
<td>{{ f[c]().dirty() }}</td>
<td>{{ f[c]().disabled() }}</td>
</tr>
}
</tbody>
</table>
<p class="small mb-0">formulaire modifié (dirty) : <strong>{{ f().dirty() }}</strong></p>
</div>
</div>
</div>
|
- ligne 4 : le formulaire n'est pas envoyé (pas de bouton d'envoi dans cet exemple) ; on empêche seulement un envoi par la touche Entrée ;
- ligne 10 : des boutons radio : le même champ [f.titre] sur chaque bouton, une valeur par bouton ;
- ligne 19 : [[formField]="f.prenom"] relie le champ HTML au champ [prenom] du formulaire, dans les deux sens, comme [ngModel] à l'exemple 05 ;
- ligne 28 : un champ numérique, pour une propriété numérique du modèle : le modèle reçoit un nombre ;
- ligne 34 : une case à cocher, pour un booléen ;
- ligne 41 : le champ [email]. On n'écrit pas d'attribut [disabled] : la directive [[formField]] le pose et le retire elle-même, selon la règle du schéma ;
- lignes 47, 55, 60 : une liste déroulante, une date, une zone de texte ;
- ligne 71 : [f().value()] : la valeur de tout le formulaire, c'est-à-dire le modèle ;
- lignes 76-83 : l'état de quatre champs. [f[c]] est le champ de nom [c] ; [fc] son état ; [touched()] (l'utilisateur est entré dans le champ, puis l'a quitté), [dirty()] (l'utilisateur a modifié sa valeur), [disabled()] : tous des signaux ;
- ligne 86 : l'état du formulaire entier : il est modifié si l'un de ses champs l'est.
[npm start 06-formulaires-signaux] ; on choisit le titre, on tape un prénom, un nom et un âge, on coche la case (l'e-mail devient modifiable), on tape l'e-mail, puis on clique sur « Nom en majuscules » :

Comparé à [ngModel] : un seul signal pour tout le formulaire, et l'état de chaque champ, sans rien écrire. Les formulaires à signaux ne savent pas relier plusieurs cases à cocher à une même liste, ni une liste à choix multiples : pour ces champs-là, on garde la technique de l'exemple 05.
Le formulaire « Créer un compte » de l'application [RdvMedecins], validé dans le navigateur, avec les mêmes règles que le serveur : titre dans la liste, prénom et nom obligatoires (20 caractères au plus), login en minuscules, mot de passe de 6 caractères au moins, confirmation.
Attention : cette validation n'est qu'un confort pour l'utilisateur (une réponse immédiate, sans aller-retour avec le serveur). Elle ne protège rien : n'importe qui peut envoyer au serveur une requête fabriquée à la main ([curl]...). Le serveur revalide donc TOUJOURS tout (cf. exemple 25).
2.7.1. La configuration de l'application
| import { bootstrapApplication } from '@angular/platform-browser';
import { provideSignalFormsConfig } from '@angular/forms/signals';
import { App } from './app/app';
bootstrapApplication(App, {
providers: [
provideSignalFormsConfig({
classes: {
// un champ en erreur n'est signalé qu'une fois "touché" (quitté)
'is-invalid': (champ) => champ.state().touched() && champ.state().invalid(),
'is-valid': (champ) => champ.state().touched() && champ.state().valid(),
},
}),
],
}).catch((erreur) => console.error(erreur));
|
- ligne 15 : le second argument de [bootstrapApplication] est la configuration de l'application : une liste de fournisseurs ([providers]), qui mettent des services et des réglages à la disposition de toute l'application (cf. exemple 14) ;
- lignes 17-23 : [provideSignalFormsConfig] règle les formulaires à signaux. [classes] associe une classe CSS à une condition sur l'état d'un champ : la directive [[formField]] pose la classe sur le champ HTML quand la condition est vraie. On utilise les classes de [Bootstrap] :
- ligne 20 : [is-invalid] (cadre rouge), si le champ a été touché ET qu'il est invalide. On n'affiche pas les erreurs d'emblée : un formulaire vide couvert de rouge est désagréable ;
- ligne 21 : [is-valid] (cadre vert), si le champ a été touché et qu'il est valide.
2.7.2. Le composant
| // =========================================================================
// 07-validation/src/app/app.ts - valider un formulaire dans le navigateur
// -------------------------------------------------------------------------
// Les règles des champs sont écrites dans le SCHÉMA du formulaire (second
// argument de form()) : required, maxLength, minLength, pattern... et
// validate() pour une règle "maison". Chaque champ connaît alors ses
// erreurs (f.nom().errors()), recalculées à chaque frappe.
// On ne les affiche pas d'emblée (un formulaire vide couvert de rouge est
// désagréable) : seulement pour un champ déjà quitté ("touché"). submit()
// marque tous les champs comme touchés, puis n'exécute l'action que si le
// formulaire est valide.
//
// Mêmes règles que le serveur RdvMedecins (formulaire "Créer un compte").
// ATTENTION : cette validation n'est qu'un CONFORT pour l'utilisateur (une
// réponse immédiate, sans aller-retour avec le serveur). Elle ne protège
// rien : n'importe qui peut envoyer au serveur une requête fabriquée à la
// main (curl...). Le serveur revalide donc TOUJOURS tout (cf. exemple 25).
// =========================================================================
import { Component, signal } from '@angular/core';
import { form, FormField, maxLength, minLength, pattern, required, submit, validate } from '@angular/forms/signals';
interface Compte {
titre: string;
prenom: string;
nom: string;
login: string;
password: string;
confirmation: string;
}
const TITRES = ['Mr', 'Mme', 'Melle'];
const FORMAT_LOGIN = /^[a-z0-9._-]{1,30}$/;
const MOT_DE_PASSE_MIN = 6;
@Component({
selector: 'app-root',
imports: [FormField],
templateUrl: './app.html',
})
export class App {
protected readonly titres = TITRES;
protected readonly modele = signal<Compte>({
titre: 'Mr',
prenom: '',
nom: '',
login: '',
password: '',
confirmation: '',
});
// le schéma : p désigne les CHEMINS des champs (p.nom, p.login...)
protected readonly f = form(this.modele, (p) => {
validate(p.titre, ({ value }) =>
TITRES.includes(value()) ? null : { kind: 'titre', message: 'Choisissez un titre dans la liste.' },
);
required(p.prenom, { message: 'Le prénom est obligatoire.' });
maxLength(p.prenom, 20, { message: 'Le prénom ne doit pas dépasser 20 caractères.' });
required(p.nom, { message: 'Le nom est obligatoire.' });
maxLength(p.nom, 20, { message: 'Le nom ne doit pas dépasser 20 caractères.' });
required(p.login, { message: 'Le login est obligatoire.' });
pattern(p.login, FORMAT_LOGIN, { message: 'Lettres minuscules non accentuées, chiffres, point, tiret, souligné.' });
required(p.password, { message: 'Le mot de passe est obligatoire.' });
minLength(p.password, MOT_DE_PASSE_MIN, { message: `Au moins ${MOT_DE_PASSE_MIN} caractères.` });
// une règle "maison", qui compare DEUX champs (valueOf lit l'autre)
validate(p.confirmation, ({ value, valueOf }) =>
value() === valueOf(p.password)
? null
: { kind: 'confirmation', message: 'La confirmation ne correspond pas au mot de passe.' },
);
});
protected readonly envoiTente = signal(false);
protected readonly succes = signal(false);
// (submit) du formulaire
protected async envoyer(evenement: SubmitEvent): Promise<void> {
evenement.preventDefault();
this.envoiTente.set(true);
// submit() marque tous les champs comme touchés (leurs erreurs
// apparaissent), puis n'exécute l'action que si tout est valide
const valide = await submit(this.f, async () => {
// ici, on enverrait le modèle au serveur (cf. exemple 25)
return null; // null : pas d'erreur
});
this.succes.set(valide);
}
}
|
- ligne 21 : les fonctions de validation des formulaires à signaux ;
- lignes 53-71 : le schéma du formulaire : chaque fonction attache une règle à un champ, désigné par son chemin ([p.prenom]), avec le message à afficher en cas d'échec :
- [required] : le champ doit être rempli ;
- [maxLength], [minLength] : une longueur maximale, minimale ;
- [pattern] : la valeur doit respecter une expression régulière ;
- [validate(chemin, fonction)] : une règle « maison ». La fonction reçoit le contexte du champ (sa valeur [value()], la valeur d'un autre champ [valueOf(...)]) et renvoie [null] (pas d'erreur) ou une erreur : un objet [{ kind, message }], où [kind] désigne le type de l'erreur (lignes 54-56 et 66-70). La règle de la confirmation compare deux champs : elle est réévaluée quand l'un OU l'autre change ;
- [Angular] fournit aussi [email], [min], [max], et des règles asynchrones ([validateHttp]), qui interrogent un serveur ;
- lignes 77-87 : l'envoi. [submit(formulaire, action)] :
- marque tous les champs comme touchés : leurs erreurs apparaissent, même pour ceux que l'utilisateur n'a jamais visités ;
- n'exécute l'action que si le formulaire est valide ;
- renvoie [true] si l'envoi a réussi. L'action renvoie [null] quand tout va bien, ou des erreurs, que l'exemple 25 obtiendra du serveur.
2.7.3. Le template
| <div class="container py-4" style="max-width: 640px">
<h1 class="h3 mb-3">Exemple 07 - la validation d'un formulaire</h1>
<!-- novalidate : on désactive la validation propre au navigateur, pour
afficher NOS messages, dans NOTRE style -->
<form class="card p-4" novalidate (submit)="envoyer($event)">
<div class="mb-3">
<label class="form-label" for="titre">Titre</label>
<select id="titre" class="form-select" [formField]="f.titre">
@for (t of titres; track t) {
<option [value]="t">{{ t }}</option>
}
</select>
<div class="invalid-feedback">{{ f.titre().errors()[0]?.message }}</div>
</div>
<div class="row g-3 mb-3">
<div class="col-sm-6">
<label class="form-label" for="prenom">Prénom</label>
<!-- la classe is-invalid (posée par la configuration, cf. main.ts)
encadre le champ de rouge ET fait apparaître le .invalid-feedback
qui le suit : on y écrit la première erreur du champ -->
<input id="prenom" class="form-control" [formField]="f.prenom">
<div class="invalid-feedback">{{ f.prenom().errors()[0]?.message }}</div>
</div>
<div class="col-sm-6">
<label class="form-label" for="nom">Nom</label>
<input id="nom" class="form-control" [formField]="f.nom">
<div class="invalid-feedback">{{ f.nom().errors()[0]?.message }}</div>
</div>
</div>
<div class="mb-3">
<label class="form-label" for="login">Login</label>
<input id="login" class="form-control" autocomplete="username" [formField]="f.login">
<div class="invalid-feedback">{{ f.login().errors()[0]?.message }}</div>
</div>
<div class="row g-3 mb-4">
<div class="col-sm-6">
<label class="form-label" for="password">Mot de passe</label>
<input id="password" type="password" class="form-control" autocomplete="new-password" [formField]="f.password">
<div class="invalid-feedback">{{ f.password().errors()[0]?.message }}</div>
</div>
<div class="col-sm-6">
<label class="form-label" for="confirmation">Confirmation</label>
<input id="confirmation" type="password" class="form-control" autocomplete="new-password"
[formField]="f.confirmation">
<div class="invalid-feedback">{{ f.confirmation().errors()[0]?.message }}</div>
</div>
</div>
<button type="submit" class="btn btn-primary">Créer le compte</button>
@if (envoiTente() && f().invalid()) {
<div class="alert alert-danger mt-3 mb-0">Le formulaire contient {{ f().errorSummary().length }} erreur(s).</div>
}
@if (succes()) {
<div class="alert alert-success mt-3 mb-0">Formulaire valide : il pourrait être envoyé au serveur.</div>
}
</form>
</div>
|
- ligne 5 : [novalidate] désactive la validation propre au navigateur (les bulles « Veuillez renseigner ce champ ») : on affiche NOS messages, dans NOTRE style ;
- ligne 13 : [f.titre().errors()] : la liste des erreurs du champ, recalculée à chaque changement. On affiche le message de la première. [?.] : s'il n'y a pas d'erreur, [[0]] vaut [undefined], et l'expression aussi ;
- lignes 21-22 : le champ, et la zone de son message. La classe [invalid-feedback] de [Bootstrap] est cachée, sauf si elle suit un champ qui a la classe [is-invalid] : il n'y a donc rien à écrire pour la faire apparaître au bon moment ;
- ligne 49 : [f().invalid()] : le formulaire est invalide si l'un de ses champs l'est ; [f().errorSummary()] (ligne 50) : les erreurs de tous ses champs.
[npm start 07-validation] ; on tape un prénom, on passe au nom, on le quitte vide, on tape un login incorrect :

Seuls les champs touchés sont signalés, en vert ou en rouge. Un clic sur « Créer le compte », formulaire vide : tous les champs deviennent touchés :

La confirmation vide est « valide » : elle est égale au mot de passe, vide lui aussi. Un formulaire correct :

Avant les formulaires à signaux, les formulaires d'[Angular] s'écrivaient avec [ngModel] (exemple 05) ou avec les formulaires réactifs ([ReactiveFormsModule]), l'API la plus utilisée dans les applications existantes. On la rencontrera forcément : voici le formulaire de l'exemple 07, réécrit avec elle.
| // =========================================================================
// 08-formulaires-reactifs/src/app/app.ts - l'API "historique" des formulaires
// -------------------------------------------------------------------------
// Avant les formulaires à signaux (Angular 22), on écrivait les formulaires
// avec les FORMULAIRES RÉACTIFS (ReactiveFormsModule). On les rencontre dans
// la plupart des applications existantes : voici le formulaire de l'exemple
// 07, réécrit avec eux.
// - un FormGroup regroupe des FormControl, un par champ ;
// - chaque FormControl a une valeur de départ et des VALIDATEURS
// (Validators.required, Validators.maxLength(20)...) ;
// - dans le template, [formGroup] relie le <form> au groupe, et
// formControlName="nom" relie un champ HTML à un contrôle ;
// - l'état d'un contrôle (value, errors, touched, invalid...) est une
// propriété ordinaire, et non un signal.
// =========================================================================
import { Component, inject, signal } from '@angular/core';
import { AbstractControl, FormBuilder, ReactiveFormsModule, ValidationErrors, Validators } from '@angular/forms';
const TITRES = ['Mr', 'Mme', 'Melle'];
// un validateur "maison" s'applique au GROUPE : il compare deux de ses champs.
// Il renvoie null (pas d'erreur) ou un objet décrivant l'erreur.
function confirmationIdentique(groupe: AbstractControl): ValidationErrors | null {
const password = groupe.get('password')?.value;
const confirmation = groupe.get('confirmation')?.value;
return password === confirmation ? null : { confirmation: true };
}
@Component({
selector: 'app-root',
imports: [ReactiveFormsModule],
templateUrl: './app.html',
})
export class App {
protected readonly titres = TITRES;
private readonly fb = inject(FormBuilder).nonNullable;
// le groupe : { nom: [valeur de départ, validateurs] }
protected readonly formulaire = this.fb.group(
{
titre: ['Mr', [Validators.required]],
prenom: ['', [Validators.required, Validators.maxLength(20)]],
nom: ['', [Validators.required, Validators.maxLength(20)]],
login: ['', [Validators.required, Validators.pattern(/^[a-z0-9._-]{1,30}$/)]],
password: ['', [Validators.required, Validators.minLength(6)]],
confirmation: [''],
},
{ validators: confirmationIdentique },
);
protected readonly succes = signal(false);
protected readonly valeur = signal('');
// un champ est signalé en erreur s'il a été touché et qu'il est invalide
protected enErreur(nom: string): boolean {
const controle = this.formulaire.get(nom);
return !!controle && controle.touched && controle.invalid;
}
// (ngSubmit) : tous les champs deviennent "touchés", leurs erreurs apparaissent
protected envoyer(): void {
this.formulaire.markAllAsTouched();
this.succes.set(this.formulaire.valid);
// getRawValue() : la valeur de tout le groupe, typée
this.valeur.set(JSON.stringify(this.formulaire.getRawValue()));
}
}
|
- ligne 18 : les classes des formulaires réactifs, dans [@angular/forms] ;
- lignes 24-28 : un validateur « maison ». C'est une fonction qui reçoit un contrôle (ici le groupe entier, pour comparer deux de ses champs) et renvoie [null] (pas d'erreur) ou un objet décrivant l'erreur ;
- ligne 32 : le composant importe [ReactiveFormsModule], qui apporte les directives [[formGroup]] et [formControlName] ;
- ligne 37 : [inject(FormBuilder)] : le composant demande un service à [Angular] (l'injection de dépendances, cf. exemple 14). [FormBuilder] aide à fabriquer les formulaires ; [nonNullable] : un champ réinitialisé reprend sa valeur de départ, et non [null] ;
- lignes 40-50 : le formulaire est un groupe ([FormGroup]) de contrôles ([FormControl]), un par champ. Chacun est décrit par sa valeur de départ et la liste de ses validateurs : [Validators.required], [Validators.maxLength(20)], [Validators.pattern(...)]... Ligne 49 : le validateur du groupe ;
- lignes 56-59 : un champ est signalé en erreur s'il a été touché et qu'il est invalide. L'état d'un contrôle ([touched], [invalid], [errors], [value]) est une propriété ordinaire, et non un signal ;
- lignes 62-67 : l'envoi. [markAllAsTouched()] fait apparaître toutes les erreurs ; [valid] dit si tout le groupe est valide ; [getRawValue()] donne sa valeur, typée d'après la description du groupe.
Le template :
| <div class="container py-4" style="max-width: 640px">
<h1 class="h3 mb-3">Exemple 08 - les formulaires réactifs</h1>
<!-- [formGroup] : ce <form> est piloté par le groupe "formulaire" -->
<form class="card p-4" novalidate [formGroup]="formulaire" (ngSubmit)="envoyer()">
<div class="mb-3">
<label class="form-label" for="titre">Titre</label>
<!-- formControlName : ce champ est le contrôle "titre" du groupe -->
<select id="titre" class="form-select" formControlName="titre">
@for (t of titres; track t) {
<option [value]="t">{{ t }}</option>
}
</select>
</div>
<div class="row g-3 mb-3">
<div class="col-sm-6">
<label class="form-label" for="prenom">Prénom</label>
<input id="prenom" class="form-control" formControlName="prenom" [class.is-invalid]="enErreur('prenom')">
<!-- errors : un objet dont chaque clé est le nom d'un validateur en échec -->
<div class="invalid-feedback">
@if (formulaire.controls.prenom.errors?.['required']) { Le prénom est obligatoire. }
@if (formulaire.controls.prenom.errors?.['maxlength']) { 20 caractères au plus. }
</div>
</div>
<div class="col-sm-6">
<label class="form-label" for="nom">Nom</label>
<input id="nom" class="form-control" formControlName="nom" [class.is-invalid]="enErreur('nom')">
<div class="invalid-feedback">
@if (formulaire.controls.nom.errors?.['required']) { Le nom est obligatoire. }
@if (formulaire.controls.nom.errors?.['maxlength']) { 20 caractères au plus. }
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label" for="login">Login</label>
<input id="login" class="form-control" formControlName="login" [class.is-invalid]="enErreur('login')">
<div class="invalid-feedback">
@if (formulaire.controls.login.errors?.['required']) { Le login est obligatoire. }
@if (formulaire.controls.login.errors?.['pattern']) { Lettres minuscules non accentuées, chiffres, point, tiret, souligné. }
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-sm-6">
<label class="form-label" for="password">Mot de passe</label>
<input id="password" type="password" class="form-control" formControlName="password"
[class.is-invalid]="enErreur('password')">
<div class="invalid-feedback">
@if (formulaire.controls.password.errors?.['required']) { Le mot de passe est obligatoire. }
<!-- les erreurs portent aussi des informations : la longueur exigée -->
@if (formulaire.controls.password.errors?.['minlength']; as e) { Au moins {{ e.requiredLength }} caractères. }
</div>
</div>
<div class="col-sm-6">
<label class="form-label" for="confirmation">Confirmation</label>
<!-- l'erreur "confirmation" est celle du GROUPE (validateur maison) -->
<input id="confirmation" type="password" class="form-control" formControlName="confirmation"
[class.is-invalid]="formulaire.controls.confirmation.touched && formulaire.hasError('confirmation')">
<div class="invalid-feedback">La confirmation ne correspond pas au mot de passe.</div>
</div>
</div>
<button type="submit" class="btn btn-primary">Créer le compte</button>
@if (valeur()) {
<div class="alert mt-3 mb-0" [class]="succes() ? 'alert-success' : 'alert-danger'">
{{ succes() ? 'Formulaire valide' : 'Formulaire invalide' }} : <code>{{ valeur() }}</code>
</div>
}
</form>
</div>
|
- ligne 4 : [[formGroup]] relie le formulaire HTML au groupe ; [(ngSubmit)] appelle la méthode d'envoi ;
- ligne 8 : [formControlName="titre"] relie le champ au contrôle [titre] du groupe ;
- ligne 17 : la classe [is-invalid] est posée par le template, à l'aide de la méthode [enErreur] ;
- lignes 20-21 : [errors] est un objet dont chaque propriété porte le nom d'un validateur en échec ([required], [maxlength]...). On écrit un message par erreur possible ;
- ligne 49 : une erreur porte aussi des informations : ici, la longueur exigée ;
- ligne 56 : l'erreur de la confirmation est celle du groupe : [formulaire.hasError('confirmation')].
[npm start 08-formulaires-reactifs] ; quelques erreurs, puis un clic sur « Créer le compte » :

Les deux API font le même travail. Les formulaires réactifs décrivent le formulaire dans le code (des contrôles, qui portent la valeur ET l'état), les formulaires à signaux partent des données (un modèle, auquel on attache des règles), et s'intègrent naturellement aux signaux du reste de l'application. Ce sont eux que nous utiliserons dans la suite du cours et dans l'étude de cas.
2.9. Exemple 09 : les pipes
Un pipe met en forme une valeur pour l'affichage, sans la modifier : [{{ valeur | pipe }}], ou, avec des paramètres, [{{ valeur | pipe: parametre1 : parametre2 }}]. [Angular] en fournit une douzaine ; on peut écrire les siens.
2.9.1. La langue des pipes
Les pipes de dates et de nombres mettent en forme selon une langue ([locale]). Par défaut, [Angular] ne connaît que l'anglais des États-Unis. Pour le français :
| import { LOCALE_ID } from '@angular/core';
import { registerLocaleData } from '@angular/common';
import localeFr from '@angular/common/locales/fr';
import { bootstrapApplication } from '@angular/platform-browser';
import { App } from './app/app';
registerLocaleData(localeFr);
bootstrapApplication(App, {
providers: [{ provide: LOCALE_ID, useValue: 'fr-FR' }],
}).catch((erreur) => console.error(erreur));
|
- ligne 18 : [registerLocaleData(localeFr)] charge les règles françaises : noms des jours et des mois, séparateur décimal, place du symbole de la monnaie... ;
- ligne 21 : un fournisseur qui donne une valeur au jeton [LOCALE_ID] : la langue utilisée par défaut par les pipes. [{ provide: jeton, useValue: valeur }] est la forme la plus simple d'un fournisseur ; l'exemple 14 y reviendra.
2.9.2. Deux pipes maison
Un pipe est une classe décorée par [@Pipe], dont la méthode [transform] reçoit la valeur placée avant la barre, et ses éventuels paramètres :
| // =========================================================================
// 09-pipes/src/app/pipes/horaire.pipe.ts - un pipe "maison"
// -------------------------------------------------------------------------
// Un pipe est une classe décorée par @Pipe, dont la méthode transform()
// reçoit la valeur placée AVANT la barre (et, éventuellement, des
// paramètres) et renvoie ce qu'il faut afficher :
// {{ creneau | horaire }} -> "8:00 - 8:20"
// {{ creneau | horaire: 'debut' }} -> "8:00"
// Un pipe est "pur" par défaut : Angular ne rappelle transform() que si la
// valeur ou les paramètres ont CHANGÉ (même objet = même résultat).
// =========================================================================
import { Pipe, PipeTransform } from '@angular/core';
export interface Creneau {
hdebut: number;
mdebut: number;
hfin: number;
mfin: number;
}
// "8:05" plutôt que "8:5"
function heure(h: number, m: number): string {
return `${h}:${String(m).padStart(2, '0')}`;
}
@Pipe({ name: 'horaire' })
export class HorairePipe implements PipeTransform {
transform(c: Creneau, partie: 'tout' | 'debut' | 'fin' = 'tout'): string {
switch (partie) {
case 'debut':
return heure(c.hdebut, c.mdebut);
case 'fin':
return heure(c.hfin, c.mfin);
default:
return `${heure(c.hdebut, c.mdebut)} - ${heure(c.hfin, c.mfin)}`;
}
}
}
|
- ligne 27 : le nom du pipe, celui qu'on écrit dans les templates ;
- ligne 29 : [transform(valeur, parametre)] renvoie ce qu'il faut afficher. Le paramètre est facultatif (valeur par défaut ['tout']) : [{{ creneau | horaire }}] affiche [8:00 - 8:20], [{{ creneau | horaire: 'debut' }}] affiche [8:00].
Un pipe est pur par défaut : [Angular] ne rappelle [transform] que si la valeur ou l'un des paramètres a changé (un autre objet, une autre chaîne). C'est ce qui le rend plus efficace qu'une méthode du composant appelée dans le template (comme [format(...)] à l'exemple 02), qui est exécutée à chaque vérification du composant.
Un second pipe, qui calcule des initiales :
| // 09-pipes/src/app/pipes/initiales.pipe.ts - "Marie PELISSIER" -> "MP"
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({ name: 'initiales' })
export class InitialesPipe implements PipeTransform {
transform(nomComplet: string): string {
return nomComplet
.split(/\s+/)
.filter((mot) => mot !== '')
.map((mot) => mot[0].toUpperCase())
.join('');
}
}
|
2.9.3. Le composant et son template
| // =========================================================================
// 09-pipes/src/app/app.ts - mettre en forme des valeurs dans un template
// -------------------------------------------------------------------------
// {{ valeur | pipe }} ou {{ valeur | pipe: parametre1 : parametre2 }} :
// un PIPE transforme une valeur pour l'affichage, sans la modifier. Angular
// en fournit une douzaine (module @angular/common) ; on peut écrire les
// siens (cf. pipes/). Un composant importe les pipes qu'il utilise.
// =========================================================================
import { Component, signal } from '@angular/core';
import {
CurrencyPipe,
DatePipe,
DecimalPipe,
JsonPipe,
KeyValuePipe,
LowerCasePipe,
PercentPipe,
SlicePipe,
TitleCasePipe,
UpperCasePipe,
} from '@angular/common';
import { Creneau, HorairePipe } from './pipes/horaire.pipe';
import { InitialesPipe } from './pipes/initiales.pipe';
@Component({
selector: 'app-root',
imports: [
CurrencyPipe,
DatePipe,
DecimalPipe,
JsonPipe,
KeyValuePipe,
LowerCasePipe,
PercentPipe,
SlicePipe,
TitleCasePipe,
UpperCasePipe,
HorairePipe,
InitialesPipe,
],
templateUrl: './app.html',
})
export class App {
protected readonly maintenant = signal(new Date());
protected readonly jour = '2026-10-05'; // une date au format de l'API RdvMedecins
protected readonly prix = 1234.5;
protected readonly taux = 0.055;
protected readonly nom = 'marie pelissier';
protected readonly medecin = { titre: 'Mme', prenom: 'Marie', nom: 'PELISSIER' };
protected readonly creneaux: Creneau[] = [
{ hdebut: 8, mdebut: 0, hfin: 8, mfin: 20 },
{ hdebut: 8, mdebut: 20, hfin: 8, mfin: 40 },
{ hdebut: 14, mdebut: 5, hfin: 14, mfin: 25 },
];
protected readonly langages = ['TypeScript', 'HTML', 'CSS', 'JavaScript', 'SQL'];
protected actualiser(): void {
this.maintenant.set(new Date());
}
}
|
- lignes 11-22 et 28-41 : un composant importe les pipes qu'il utilise, ceux d'[Angular] ([@angular/common]) comme les siens ;
- ligne 46 : une date au format de l'API [RdvMedecins] : le pipe [date] accepte un objet [Date], un nombre (millisecondes) ou une chaîne ISO.
| <div class="container py-4" style="max-width: 820px">
<h1 class="h3 mb-3">Exemple 09 - les pipes</h1>
<table class="table table-sm align-middle">
<thead><tr><th>Dans le template</th><th>Affiché</th></tr></thead>
<tbody>
<tr><td><code>{{ '{{' }} maintenant() | date {{ '}}' }}</code></td><td>{{ maintenant() | date }}</td></tr>
<tr><td><code>{{ '{{' }} maintenant() | date: 'fullDate' {{ '}}' }}</code></td><td>{{ maintenant() | date: 'fullDate' }}</td></tr>
<tr><td><code>{{ '{{' }} maintenant() | date: 'HH:mm:ss' {{ '}}' }}</code></td><td>{{ maintenant() | date: 'HH:mm:ss' }}</td></tr>
<tr><td><code>{{ '{{' }} jour | date: 'EEEE d MMMM y' {{ '}}' }}</code></td><td>{{ jour | date: 'EEEE d MMMM y' }}</td></tr>
<tr><td><code>{{ '{{' }} jour | date: 'longDate' : '' : 'en-US' {{ '}}' }}</code></td><td>{{ jour | date: 'longDate' : '' : 'en-US' }}</td></tr>
<tr><td><code>{{ '{{' }} prix | currency: 'EUR' {{ '}}' }}</code></td><td>{{ prix | currency: 'EUR' }}</td></tr>
<tr><td><code>{{ '{{' }} prix | number: '1.0-0' {{ '}}' }}</code></td><td>{{ prix | number: '1.0-0' }}</td></tr>
<tr><td><code>{{ '{{' }} taux | percent: '1.1-1' {{ '}}' }}</code></td><td>{{ taux | percent: '1.1-1' }}</td></tr>
<tr><td><code>{{ '{{' }} nom | uppercase {{ '}}' }}</code></td><td>{{ nom | uppercase }}</td></tr>
<tr><td><code>{{ '{{' }} nom | titlecase {{ '}}' }}</code></td><td>{{ nom | titlecase }}</td></tr>
<tr><td><code>{{ '{{' }} 'RDV' | lowercase {{ '}}' }}</code></td><td>{{ 'RDV' | lowercase }}</td></tr>
<tr><td><code>{{ '{{' }} langages | slice: 0 : 3 {{ '}}' }}</code></td><td>{{ langages | slice: 0 : 3 }}</td></tr>
<tr><td><code>{{ '{{' }} medecin | json {{ '}}' }}</code></td><td><code>{{ medecin | json }}</code></td></tr>
<!-- deux pipes à la suite : le résultat du premier passe au second -->
<tr><td><code>{{ '{{' }} nom | titlecase | initiales {{ '}}' }}</code></td><td>{{ nom | titlecase | initiales }}</td></tr>
</tbody>
</table>
<h2 class="h6">keyvalue : parcourir les propriétés d'un objet</h2>
<ul>
@for (p of medecin | keyvalue; track p.key) {
<li>{{ p.key }} = {{ p.value }}</li>
}
</ul>
<h2 class="h6">Un pipe maison : horaire</h2>
<ul>
@for (c of creneaux; track $index) {
<li>{{ c | horaire }} (début : {{ c | horaire: 'debut' }})</li>
}
</ul>
<button class="btn btn-sm btn-outline-primary" (click)="actualiser()">Actualiser l'heure</button>
</div>
|
- lignes 6-20 : une ligne par pipe : l'expression, puis son résultat. [{{ '{{' }}] affiche les caractères [{{], qu'on ne peut pas écrire tels quels dans un template ;
- lignes 6-10 : le pipe [date], avec un format prédéfini ([fullDate], [longDate]...) ou composé ([EEEE d MMMM y], [HH:mm:ss]). Ligne 10 : ses deuxième et troisième paramètres sont le fuseau horaire et la langue : ici, l'anglais, pour cette seule date ;
- ligne 11 : [currency], un montant dans une monnaie ;
- ligne 12 : [number: '1.0-0'] : au moins 1 chiffre avant la virgule, entre 0 et 0 chiffre après : un arrondi à l'unité ;
- ligne 13 : [percent], un pourcentage ;
- lignes 14-16 : majuscules, initiales en majuscules, minuscules ;
- ligne 17 : [slice], une partie d'une liste ou d'une chaîne ;
- ligne 18 : [json], un objet au format JSON (très utile pour déboguer) ;
- ligne 20 : deux pipes à la suite : le résultat du premier est passé au second ;
- ligne 26 : [keyvalue] transforme un objet en une liste de paires [{ key, value }], que [@for] peut parcourir ;
- ligne 34 : notre pipe [horaire], sans puis avec paramètre.
[npm start 09-pipes] :

[Angular] fournit aussi le pipe [async], qui affiche la dernière valeur d'un observable (cf. exemple 19), et [i18nPlural] pour les pluriels. [ngx-translate] fournit le pipe [translate], qui traduit un texte (exemple 21).