Cours Angular · Module 8: Formulaires
Des formulaires qui écoutent
Dans cette leçon5
Un formulaire ordinaire, tu lui demandes sa valeur au moment où tu cliques sur "envoyer". Un formulaire réactif fonctionne à l'envers - c'est un dojo vivant qui rapporte chaque mouvement en temps réel. La clé de cette leçon : tu ne demandes pas sa valeur au formulaire, tu écoutes ses changements. Chaque champ possède un flux valueChanges qui coule comme une rivière - et c'est toi qui le filtres avec les opérateurs RxJS.
Charger les données avant d'écouter
Avant d'écouter la rivière, il faut la remplir. Quand tu ouvres la fiche d'un samouraï déjà inscrit au dojo, tu poses dans le formulaire les valeurs connues - et deux méthodes s'offrent à toi, qui ne se valent pas du tout.
1loadSamurai(samurai: Samurai): void {
2 // patchValue ne met à jour que les champs fournis
3 this.form.patchValue({ name: samurai.name });
4
5 // setValue exigerait TOUS les champs du groupe
6 // this.form.setValue({ name: samurai.name }); // erreur : clan manquant
7
8 // reset remet le formulaire à son état initial
9 // this.form.reset();
10}Retiens cette différence, elle revient sans cesse : setValue() exige tous les champs, patchValue() ne met à jour que ceux qui sont fournis. Ce n'est pas une question de rapidité - les deux écrivent dans le même formulaire - et patchValue() n'a rien de réservé au FormArray : il fonctionne sur n'importe quel FormGroup. Prends setValue() quand tu veux qu'Angular proteste si tu oublies un champ, et patchValue() pour une mise à jour partielle. reset(), lui, vide tout et rend au formulaire son état de départ.
Écouter les changements
Le formulaire est rempli, la rivière peut couler. Commençons par le flux valueChanges : tu peux écouter un seul champ, le formulaire entier, ou encore son statut (valide / invalide). Lis comment on filtre une telle rivière.
1ngOnInit(): void {
2 // Un seul champ - avec filtrage
3 this.form.get('searchQuery')?.valueChanges.pipe(
4 debounceTime(300), // attendre 300 ms après la dernière frappe
5 distinctUntilChanged(), // ignorer si la valeur n'a pas changé
6 takeUntilDestroyed(this.destroyRef)
7 ).subscribe(query => console.log('Recherche:', query));
8
9 // Le formulaire entier d'un coup
10 this.form.valueChanges.pipe(
11 takeUntilDestroyed(this.destroyRef)
12 ).subscribe(value => console.log('Formulaire:', value));
13
14 // Le statut de validation du formulaire
15 this.form.statusChanges.pipe(
16 takeUntilDestroyed(this.destroyRef)
17 ).subscribe(status => console.log('Statut:', status));
18}Trois opérateurs font ici tout le travail. debounceTime(300) attend que l'utilisateur cesse de taper - sans lui, tu enverrais une requête après chaque lettre. distinctUntilChanged() écarte les répétitions. Et takeUntilDestroyed() est ton gardien obligatoire : il se détache tout seul du flux quand le composant disparaît - sans lui, il te resterait une fuite de mémoire. Retiens ce trio, il revient dans chaque formulaire réactif.
La propriété à connaître s'appelle donc valueChanges : pas onChange, qui n'existe pas sur un champ Angular, ni value$, une convention de nommage que le framework n'emploie pas ici. Quant à subscribe, c'est la méthode que tu appelles sur l'Observable, jamais la propriété qui te le fournit. Sa voisine statusChanges émet le statut de validation, et celui-ci n'a que quatre valeurs possibles : VALID, INVALID, PENDING, DISABLED - PENDING tant qu'un validateur asynchrone interroge encore le serveur, DISABLED quand le champ est désactivé.
Des validateurs qui changent à la volée
Parfois les règles dépendent du choix de l'utilisateur : il a choisi le contact par e-mail - exige un e-mail ; par téléphone - exige un numéro. Tu écoutes un champ et tu remplaces les validateurs de l'autre.
1this.form.get('contactMethod')?.valueChanges.pipe(
2 takeUntilDestroyed(this.destroyRef)
3).subscribe(method => {
4 const email = this.form.get('email')!;
5 const phone = this.form.get('phone')!;
6
7 if (method === 'email') {
8 email.setValidators([Validators.required, Validators.email]);
9 phone.clearValidators();
10 } else {
11 phone.setValidators([Validators.required]);
12 email.clearValidators();
13 }
14
15 email.updateValueAndValidity(); // sans cela, le changement reste sans effet
16 phone.updateValueAndValidity();
17});Fais bien attention aux deux dernières lignes - c'est l'étape qu'on oublie le plus souvent. setValidators() ne fait que substituer les règles, mais il ne recalcule pas le statut du champ. Seul updateValueAndValidity() ordonne à Angular de réexaminer le champ depuis le début. L'omettre est l'erreur classique : les règles semblent changées, et le formulaire continue obstinément d'afficher son ancien état.
Sur un même champ, la séquence est donc toujours la même, ligne après ligne : d'abord emailControl.clearValidators() pour effacer les anciennes règles, puis emailControl.setValidators([Validators.required, Validators.email]) pour poser les nouvelles, et enfin emailControl.updateValueAndValidity() pour faire appliquer le verdict.
Le pont vers les signaux : toSignal
Un flux s'écoute très bien, mais dans le template un signal est plus commode. toSignal() construit le pont : il prend valueChanges et le convertit en signal, que tu lis ensuite dans le template comme n'importe quel autre.
1nameSignal = toSignal(
2 this.form.get('name')!.valueChanges,
3 { initialValue: '' }
4);
5
6isValid = toSignal(
7 this.form.statusChanges.pipe(map(s => s === 'VALID')),
8 { initialValue: false }
9);toSignal() rebranche le flux sur le monde des signaux - désormais tu écris {{ nameSignal() }} dans le template au lieu de t'abonner à la main. Ne le confonds pas avec ses voisins : signal() crée un signal à partir d'une valeur ordinaire, computed() en dérive un à partir d'autres signaux, et asSignal() n'existe tout simplement pas. initialValue n'est pas obligatoire, mais sans elle le signal renvoie undefined jusqu'à la première émission du flux, et son type inclut undefined ; avec initialValue, il possède une valeur dès le premier instant. C'est la façon la plus propre de montrer l'état du formulaire dans la vue.
Ton propre champ de formulaire : ControlValueAccessor
Pour finir, la technique du maître : un widget maison qui se comporte comme un champ natif. Les étoiles de notation doivent fonctionner avec formControlName comme un simple input. C'est l'interface ControlValueAccessor qui le permet - un contrat de quatre méthodes par lesquelles Angular dialogue avec ton widget.
1@Component({
2 selector: 'app-star-rating',
3 template: `
4 @for (star of stars; track $index; let i = $index) {
5 <span (click)="setRating(i + 1)" [class.filled]="i < value">★</span>
6 }
7 `,
8 providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: StarRatingComponent, multi: true }]
9})
10export class StarRatingComponent implements ControlValueAccessor {
11 stars = [1, 2, 3, 4, 5];
12 value = 0;
13 private onChange: (v: number) => void = () => {};
14
15 writeValue(value: number): void { this.value = value || 0; }
16 registerOnChange(fn: (v: number) => void): void { this.onChange = fn; }
17 registerOnTouched(fn: () => void): void {}
18
19 setRating(rating: number): void {
20 this.value = rating;
21 this.onChange(rating); // prévenir le formulaire de la nouvelle valeur
22 }
23}Le contrat se lit comme une conversation à double sens, et l'ordre de déclaration suit cette conversation. writeValue(value) vient en premier : c'est le formulaire qui dit au widget "affiche cette valeur" (quand les données arrivent de l'extérieur). registerOnChange(fn) suit et te remet la fonction que tu appelles dans l'autre sens - onChange(rating) dit au formulaire "l'utilisateur a choisi tant d'étoiles". registerOnTouched(fn) ferme la marche, pour signaler que le champ a été touché. L'enregistrement dans NG_VALUE_ACCESSOR fait qu'Angular traite ton composant comme un champ natif - et cela suffit pour que formControlName="rating" fonctionne. Aucune autre interface ne joue ce rôle : Validator sert à valider et non à échanger la valeur, OnInit n'est qu'un crochet de cycle de vie, et FormControlDirective est une directive interne d'Angular, pas un contrat que tu implémentes.
Retiens une seule chose de cette leçon : un formulaire réactif est un flux que tu écoutes, pas une boîte que tu interroges. Tu le remplis avec patchValue(), tu le filtres avec les opérateurs, tu le relies aux signaux par toSignal(), et tu y branches tes propres champs grâce au contrat ControlValueAccessor.
Tu as repéré une erreur dans cette leçon ?
Teste tes connaissances
Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.
1. Quelle est la différence entre setValue() et patchValue() ?
2. Quelle propriété du champ utilises-tu pour écouter ses changements de valeur sous forme d'Observable ?
Voici 2 des 5 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Éditeur de code
Complète le formulaire d'édition du samouraï avec le chargement des données.
- Tri vertical
Ordonne les étapes pour changer dynamiquement les validateurs d'un champ en réponse au changement d'un autre champ (ordre d'exécution, ligne par ligne).
- Clique dans l'ordre
Construis un abonnement sûr aux changements du formulaire avec nettoyage automatique : this.form.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)). Clique sur les éléments dans l'ordre.
- Tri vertical
Ordonne les méthodes de l'interface ControlValueAccessor dans leur ordre de déclaration typique au sein de la classe (d'abord l'écriture de la valeur, puis l'enregistrement des callbacks).