Les formulaires sont centraux dans nos applications Web. Angular fournit deux méthodes pour construire des formulaires :
Il faut savoir que les deux approches apportent les mêmes fonctionnalités, mais leur implémentation sera très différente.
Dans cet article, nous allons apprendre à bien utiliser les Reactive Forms en utilisant les fonctionnalités fournies par Angular.
Nous finirons par une comparaison avec les Template-Driven Forms, pour que vous vous rendiez compte de l'avantage des Reactive Forms.
Si vous préférez le format vidéo, voici la vidéo YouTube correspondant à cet article.
Basé sur un formulaire standard HTML
Tout d'abord, il faut savoir que les formulaires Angular se basent sur des éléments HTML classiques.
En HTML, un formulaire standard est englobé par une balise <form> qui contient des champs définis par les balises <input>, <textarea> ou <select>.
Le formulaire contiendra aussi un bouton de type “submit” qui permettra de déclencher la validation du formulaire.
<form>
<div class="mb-3">
<label for="firstname" class="form-label">Firstname</label>
<input id="firstname" type="text" class="form-control" />
</div>
<div class="mb-3">
<label for="lastname" class="form-label">Lastname</label>
<input id="lastname" type="text" class="form-control" />
</div>
<div class="mb-3">
<label for="age" class="form-label">Age</label>
<input id="age" type="number" class="form-control" />
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
Formulaire natif en HTML
Notre objectif est d'utiliser les outils fournis par Angular, pour manipuler et accéder aux valeurs de notre formulaire.
Dans cet article, je vais utiliser des classes CSS (form-label, form-control, etc.) pour styliser mes formulaires. Ces classes CSS n'ont aucun impact sur le fonctionnement des formulaires, elles servent uniquement à les styliser.
Reactive Forms avec FormGroup et FormControl
Les Reactive Forms est aujourd'hui l'approche conseillée par Angular. Elle permet de gérer à la fois les formulaires les plus simples, mais aussi les plus complexes. Vous ne pouvez pas vous tromper en choisissant les Reactive Forms pour vos formulaires.
Lorsqu'on utilise des formulaires avec Angular, nous allons retrouver des FormGroups et des FormControls.
Avec les Reactive Forms, un formulaire va correspondre à un FormGroup.
Un FormGroup est un objet qui correspond à un groupe de champ. Notre Reactive Form a besoin d'un FormGroup pour fonctionner, ce FormGroup sera attaché à la balise <form> grâce à l'attribut formGroup.
Ce FormGroup contiendra plusieurs champs, définis par des FormControls.
Un FormControl va correspondre à un champ et va être attaché à une balise <input>, <textarea> ou <select> grâce à l'attribut formControlName.
Par exemple, voici ci-dessous un FormGroup correspondant à un formulaire avec les champs "email" et "password".
const formGroup = new FormGroup({
email: new FormControl(''),
password: new FormControl('')
});
Il existe aussi l'objet FormArray qui permet de gérer des formulaires dynamiques. C'est une fonctionnalité avancée qui fera l'objet d'un article à part, alors abonnez-vous à la newsletter pour ne pas le louper 😉
Création d’un Reactive Form simple
Créons ensemble notre premier formulaire avec les Reactive Forms. Reprenons l’exemple du formulaire avec nos trois champs : Firstname, Lastname et Age.
Tout d’abord, pour utiliser les Reactive Forms, nous devons importer le module ReactiveFormsModule dans notre standalone components ou dans notre AppModule.
@Component({
...,
standalone: true,
imports: [ReactiveFormsModule]
})
export class ReactiveForm1Component { }
Fichier reactive-form-1.component.ts
Côté TypeScript du composant, nous devons créer notre FormGroup avec trois FormControls correspondants aux champs de notre formulaire.
import { Component } from '@angular/core';
import { FormGroup, FormControl, ReactiveFormsModule } from '@angular/forms';
@Component({
selector: 'app-reactive-form-1',
standalone: true,
templateUrl: './reactive-form-1.component.html',
imports: [ReactiveFormsModule],
})
export class ReactiveForm1Component {
formGroup = new FormGroup({
firstname: new FormControl(''),
lastname: new FormControl(''),
age: new FormControl(0),
});
onSubmit() {
console.log(this.formGroup.value);
}
}
Fichier reactive-form-1.component.ts
Ensuite côté HTML, nous devons attacher notre formGroup à la balise form avec la directive [formGroup]=”formGroup”.
Puis nous devons faire le lien entre les inputs et nos FormControl en rajoutant l’attribut formControlName=”nom_du_formcontrol”.
Enfin, nous devons ajouter l’appel de notre méthode onSubmit au sein de l’événement ngSubmit qui va s’exécuter lors du submit du formulaire.
<form [formGroup]="formGroup" (ngSubmit)="onSubmit()">
<div class="mb-3">
<label for="firstname" class="form-label">Firstname</label>
<input id="firstname" type="text" formControlName="firstname" class="form-control" />
</div>
<div class="mb-3">
<label for="lastname" class="form-label">Lastname</label>
<input id="lastname" type="text" formControlName="lastname" class="form-control" />
</div>
<div class="mb-3">
<label for="age" class="form-label">Age</label>
<input id="age" type="number" formControlName="age" class="form-control" />
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
Fichier reative-form-1.component.html
Nous pouvons maintenant utiliser notre premier Reactive Form et vérifier que l'on récupère bien les valeurs de notre formulaire avec this.formGroup.value.
Démonstration d'un Reactive Form simple
Nous avons créé notre premier Reactive Form, l'avantage d'un Reactive Form est que toute la logique des données du formulaire est gérée côté TypeScript par notre FormGroup.
Les Validators
Avec Angular, nous pouvons utiliser des Validators qui nous permettent de définir des règles sur nos FormControls. Si ces règles ne sont pas respectées alors le FormControl est considéré comme invalide.
Nous pouvons vérifier à l'état de nos FormControls grâce aux attributs valid ou invalid pour vérifier si un FormControl est valide.
Avec les Reactive Forms, nous pouvons ajouter des Validators lorsque l’on instancie notre FormControl.
Dans l’exemple ci-dessous, nous créons un FormControl avec le Validator required qui lève une erreur si la valeur du FormControl est vide ou null.
// Création d'un FormControl avec le Validators.required
const formControl = new FormControl('', [Validators.required]);
console.log(formControl.errors); // { required: true }
console.log(formControl.valid); // false
// On change la valeur du FormControl avec une valeur correcte
formControl.setValue('Hello');
console.log(formControl.errors); // null
console.log(formControl.valid); // true;
Nous pouvons vérifier qu’un FormControl respecte ses Validators avec les propriétés valid ou invalid. Nous pouvons aussi voir le détails des erreurs des Validators avec la propriété errors.
Ces Validators vont nous être très utiles pour afficher à l'utilisateur les champs qui ne sont pas correctement renseignés.
Création d'un Reactive Form avec des Validators
Utilisons maintenant ces Validators dans un vrai formulaire. Prenons maintenant un formulaire pour publier un commentaire qui ne possède qu’un seul FormControl “comment”.
Le champ “comment” est obligatoire et le commentaire doit faire au minimum 3 caractères. Pour cela nous allons utiliser les Validators required et maxLength.
@Component({ ... })
export class ReactiveForm2Component {
formGroup = new FormGroup({
comment: new FormControl('', [Validators.required, Validators.minLength(3)]),
});
onSubmit() {
console.log(this.formGroup.value);
}
}
Fichier reactive-form-2.component.ts
Côté HTML, nous pouvons utiliser les attributs “invalid” et hasError pour afficher un message d’erreur à l’utilisateur.
Si le champ est invalide, nous ajoutons la classe CSS "is-invalid" pour styliser notre input en rouge. Et nous affichons un message d’erreur qui précise quelle erreur est remontée.
<form [formGroup]="formGroup" (ngSubmit)="onSubmit()">
<div class="mb-3">
<label for="comment" class="form-label">Comment</label>
<textarea id="comment" formControlName="comment" class="form-control"
[class.is-invalid]="formGroup.controls.comment.invalid"></textarea>
<small class="text-danger" *ngIf="formGroup.controls.comment.invalid">
<ng-container *ngIf="formGroup.controls.comment.hasError('required')">
The comment is required
</ng-container>
<ng-container *ngIf="formGroup.controls.comment.hasError('minlength')">
The comment must have a minimum of 3 characters
</ng-container>
</small>
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
Fichier reactive-form-2.component.html
Voici le résultat du Reactive Form avec les Validators. Vous voyez que l'état du champ est mis à jour lorsque l'utilisateur saisie une valeur.
Reactive Form avec Validators et affichage d'une erreur si le FormControl est invalide
Grâce aux Validators, nous pouvons définir des règles pour nos champs de formulaires. Ces règles nous permettent de mettre en place des contrôles et d'afficher des erreurs à l'utilisateur pour améliorer son expérience.
Dans notre dernière exemple, notre formulaire affichait directement une erreur à l’utilisateur comme quoi son champ était incorrect.
Afin de ne pas agresser directement les yeux de notre utilisateur, il est préférable d’afficher cette erreur qu’une fois que l’utilisateur a touché ou modifié le champ et que le champ demeure invalide.
Les FormControls nous permettent de coder cette fonctionnalité en nous fournissant les attributs “touched” et “dirty”.
const formControl = new FormControl('');
formControl.touched;
formControl.dirty;
formControl.invalid;
Nous allons ajouter une méthode isInvalidAndTouchedOrDirty() qui va retourner true lorsque le FormControl est invalide ET qu’il a été touché ou modifié par l’utilisateur.
isInvalidAndTouchedOrDirty(formControl: FormControl) {
return formControl.invalid && (formControl.touched || formControl.dirty);
}
Enfin, pour gérer les cas où l'utilisateur tente de submit le formulaire sans avoir toucher les champs. Nous allons appeler la fonction markAllAsTouched() qui va permettre de mettre en lumière tous les champs invalides à l'utilisateur.
onSubmit() {
this.formGroup.markAllAsTouched();
if (this.formGroup.invalid) {
return;
}
// Do stuff...
}
Voici le code TypeScript du composant pour un formulaire possédant les champs "title" et "comment".
@Component({ ... })
export class ReactiveForm3Component {
formGroup = new FormGroup({
title: new FormControl('', [Validators.required]),
comment: new FormControl('', [Validators.required, Validators.minLength(3)]),
});
isInvalidAndTouchedOrDirty(formControl: FormControl) {
return formControl.invalid && (formControl.touched || formControl.dirty);
}
onSubmit() {
this.formGroup.markAllAsTouched();
if (this.formGroup.invalid) {
return;
}
}
}
Fichier reactive-form-3.component.ts
Voici le code TypeScript du composant pour un formulaire possédant les champs "title" et "comment".
<form [formGroup]="formGroup" (ngSubmit)="onSubmit()">
<div class="mb-3">
<label for="title" class="form-label">Title</label>
<input id="title" formControlName="title" class="form-control"
[class.is-invalid]="isInvalidAndTouchedOrDirty(formGroup.controls.title)" />
<small class="text-danger" *ngIf="isInvalidAndTouchedOrDirty(formGroup.controls.title)">
The title is required
</small>
</div>
<div class="mb-3">
<label for="comment" class="form-label">Comment</label>
<textarea id="comment" formControlName="comment" class="form-control"
[class.is-invalid]="isInvalidAndTouchedOrDirty(formGroup.controls.comment)"></textarea>
<small class="text-danger" *ngIf="isInvalidAndTouchedOrDirty(formGroup.controls.comment)">
<ng-container *ngIf="formGroup.controls.comment.hasError('required')">
The comment is required
</ng-container>
<ng-container *ngIf="formGroup.controls.comment.hasError('minlength')">
The comment must have a minimum of 3 characters
</ng-container>
</small>
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
Fichier reactive-form-3.component.html
Voici le résultat lorsqu'on utilise bien les attributs invalid, touched et dirty.
Reactive Form avec Validators et affichage des erreurs avec touched et dirty
S’abonner aux modifications avec valueChanges
Angular nous permet aussi de nous abonner aux modifications de nos FormControls ou de notre FormGroup grâce à l’attribut valueChanges qui retourne un Observable.
formGroup.valueChanges.subscribe(formValue => {
});
formGroup.controls.firstName.subscribe(firstNameValue => {
});
Prenons un formulaire avec un champ “Price” et un champ “Quantity”. Pour aider à la saisie, nous voulons afficher le prix total à l’utilisateur.
<form [formGroup]="formGroup">
<div class="row mb-3">
<div class="col">
<label for="price" class="form-label">Price</label>
<input id="price" formControlName="price" class="form-control" />
</div>
<div class="col">
<label for="quantity" class="form-label">Quantity</label>
<input id="quantity" formControlName="quantity" class="form-control" />
</div>
</div>
<div class="mb-3">
<div>Total price : {{ totalPrice }} €</div>
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
Fichier reactive-form-4.component.html
Côté Typescript, nous allons recalculer le prix total à chaque modification du formulaire. Pour cela nous allons utiliser l’Observable valueChanges du FormGroup.
@Component({ ... })
export class ReactiveForm4Component {
formGroup = new FormGroup({
price: new FormControl(0),
quantity: new FormControl(0),
});
totalPrice = 0;
constructor() {
this.formGroup.valueChanges.subscribe((formValue) => {
this.totalPrice = formValue.price! * formValue.quantity!;
});
}
}
Fichier reactive-form-4.component.ts
Voici le résultat, le prix total se met à jour automatiquement à chaque changement dans le formulaire.
Reactive Form avec ValueChanges
Typage des valeurs (value, getRawValue, nonNullable)
Dernier point avant de vous quitter, nous allons analyser le typage des valeurs retournés par les FormGroup ou FormControl car il peut y avoir des surprises.
Par défaut, la valeur des FormControls est nullable, il faut donc faire attention lorsque l’on manipule nos variables pour ne pas créer d’erreur.
// Example 1 : By Default, a FormControl is nullable
const firstname = new FormControl('John');
firstname.value; // 'John' (type: string | null)
firstname.reset();
firstname.value; // null
firstname.value.substring(1); // Error : Object is possibly 'null'
Même si cela dépend de notre besoin, c’est quand même dommage car la majorité du temps, les développeurs ne conçoivent pas des formulaires qui se basent sur des valeurs nulles.
Heureusement, nous pouvons définir des FormControl nonNullable pour avoir directement une valeur pertinente.
// Example 2 : We can create a nonNullable FormControl
const lastname = new FormControl('John', { nonNullable: true });
lastname.value; // 'John' (type: string)
lastname.reset();
lastname.value; // ''
lastname.value.substring(1); // 'ohn'
Une valeur possiblement undefined
Lorsque l’on accède à la valeur d’un FormGroup, tous ses éléments peuvent être undefined. En effet, les FormControls peuvent être désactivés et quand ils sont désactivés le FormGroup renvoit leur valeur à undefined.
Cela dépend de notre besoin, mais la majorité du temps, les développeurs se moquent qu’un champ soit désactivés ou non lorsqu'il récupére les données du formulaire.
Pour récupérer la valeur du FormGroup peu importe les FormControls désactivés, nous pouvons utiliser la méthode getRawValue() qui va renvoyer des valeurs qui ne seront pas undefined.
// Example 3 : FormGroup.value returns undefined when a FormControl is disabled
const formGroup = new FormGroup({
firstname: new FormControl('John', { nonNullable: true }),
lastname: new FormControl('Doe', { nonNullable: true }),
});
formGroup.controls.firstname.disable();
console.log(formGroup.value);
// value: { firstname: undefined, lastname: 'Doe' }
// type: { firstname: string | undefined, lastname: string | undefined }
console.log(formGroup.getRawValue());
// value: { firstname: 'John', lastname: 'Doe' }
// type: { firstname: string, lastname: string }
Utiliser l'option nonNullable avec getRawValue() va nous faciliter la vie dans la plupart de nos formulaires.
Les concepts de FormGroup, FormControl, Validators et valueChanges sont aussi présents dans les Template-Driven Forms.
Avec les Reactive Forms, les FormGroups, FormControls et Validators, sont définis dans la partie TypeScript.
Avec les Template-Driven Forms, les FormGroups, FormControls et Validators sont générés à partir du Template HTML.
Pour utiliser les Template-Driven Forms, nous devons importer le module FormsModule dans notre standalone component ou notre AppModule.
@Component({
...,
standalone: true,
imports: [FormsModule]
})
export class TemplateDrivenForm1Component { }
Si nous souhaitons manipuler l'objet FormGroup de notre formulaire avec les Template-Driven Forms, nous devons le récupérer à partir du template.
Pour cela, nous devons créer une variable de référence sur la balise <form> avec comme valeur ngForm. Et nous accédons à l'objet FormGroup avec l'attribut form du ngForm.
<form #ngForm="ngForm">
<!-- {{ngForm.form.value}} -->
<!-- ... -->
</form>
Pour l'utiliser côté TypeScript, nous devons utiliser le decorateur @ViewChild.
@ViewChild('ngForm') ngForm!: NgForm;
onSubmit() {
console.log(this.ngForm.form.value);
}
❌ Avec les Template-Driven Forms, il faut que la balise <form> soit bien accessible lorsque l'on utilise la variable ngForm côté TypeScript sinon celle-ci sera nulle. Il faut tout le temps faire attention au cycle de vie de nos composants et à la détection de changements de Angular pour ne pas se retrouver avec des erreurs.
✅ Alors qu'avec les Reactive Forms, l'objet FormGroup est toujours défini et directement accessible. Peu importe si notre formulaire est affiché ou non.
Accéder aux FormControls
Les FormControls sont générés à partir des balises <input> en liant l'input avec une variable côté TypeScript avec l'attribut ngModel et name.
<div class="mb-3">
<label for="firstname" class="form-label">Firstname</label>
<input id="firstname" type="text" class="form-control"
[(ngModel)]="firstname" name="firstname"/>
</div>
Ajout des attributs [(ngModel)] et name
@Component({ ... })
export class TemplateDrivenForm1Component {
firstname = '';
}
Variable firstname (liée à l'input grâce à l'attribut [(ngModel)]
Pour accéder à l'objet FormControl et à ses attributs invalid, touched ou dirty, nous devons créer une variable de référence avec la valeur ngModel. Nous accédons à l'objet FormControl avec l'attribut control du ngModel.
<div class="mb-3">
<label for="firstname" class="form-label">Firstname</label>
<input id="firstname" type="text" class="form-control"
[(ngModel)]="firstname" name="firstname" #firstnameNgModel="ngModel"/>
<!-- {{firstnameNgModel.control.value}} -->
<!-- {{firstnameNgModel.control.invalid}} -->
<!-- {{firstnameNgModel.control.touched}} -->
</div>
Accès au FormControl à partir de la ariable de référence
❌ L'inconvénient est de devoir déclarer à chaque fois le ngModel, le name et la variable de référence, pour pouvoir accéder à l'état (invalid, touched, dirty, etc.) de notre FormControl.
❌ De plus, comme pour le FormGroup, si nous voulons y accéder côté TypeScript, il faudra faire très attention avec le @ViewChild pour ne pas avoir un FormControl null.
✅ Alors qu'avec les Reactive Forms, notre objet FormGroup est directement défini et accessible où et quand on veut. Et nous pouvons accéder directement à tous ses FormControls et à leur état (invalid, touched, dirty, etc.).
Pour appliquer des Validators à nos FormControls avec les Template-Driven Forms, nous devons ajouter à nos inputs les attributs HTML correspondant comme par exemple : "required", "minlength", etc.
<div class="mb-3">
<label for="comment" class="form-label">Comment</label>
<textarea id="comment" #commentControl="ngModel" [(ngModel)]="comment" name="comment" class="form-control"
[class.is-invalid]="commentControl.invalid" required minlength="3"></textarea>
<small class="text-danger" *ngIf="commentControl.invalid">
<ng-container *ngIf="commentControl.hasError('required')">
The comment is required
</ng-container>
<ng-container *ngIf="commentControl.hasError('minlength')">
The comment must have a minimum of 3 characters
</ng-container>
</small>
</div>
Application des validators avec les attributs "required" et "minlength"
Une fois appliqué, notre FormControl sera considéré comme invalide s'il ne respecte pas ses Validators. Ce qui nous permet d'afficher des erreurs à l'utilisateur.
❌ L'inconvénient ici des Template-Driven Forms est de ne pas avoir rapidement une vision d'ensemble de ses FormControls et de leurs Validators. Ici les exemples sont simples, mais imaginez des formulaires HTML avec plus de 300 lignes de code.
✅ Alors qu'avec les Reactive Forms, on voit tout d'un seul coup d'oeil en regardant notre objet FormGroup défini côté TypeScript.
Nous pouvons aussi utiliser l'Observable valueChanges, il nous suffit d'utiliser l'objet NgForm récupéré depuis le template.
@Component({ ... })
export class TemplateDrivenForm4Component implements OnInit {
@ViewChild('ngForm', { static: true }) ngForm!: NgForm;
price = 0;
quantity = 0;
totalPrice = 0;
ngOnInit() {
this.ngForm.form.valueChanges.subscribe((formGroupValue) => {
this.totalPrice = formGroupValue.price! * formGroupValue.quantity!;
});
}
}
Utilisation du valueChanges avec un Template-Driven Form
❌ Attention à bien vérifier que le formulaire est accessible, sinon la variable ngForm sera nulle. Si l'affichage de votre formulaire est géré avec un *ngIf, il faudra faire encore plus attention.
Ici par exemple, je suis obligé d'utiliser l'attribut "{static: true}" et d'utiliser le valueChanges dans le ngOnInit pour éviter une erreur.
❌ Attention aussi, car le valueChanges peut être exécuté plusieurs fois rien qu'à l'affichage du formulaire, car celui-ci se construit au fûr et à mesure que ses inputs HTML s'affiche dans le DOM.
✅ Avec les Reactive Forms, j'ai directement accès à mon objet FormGroup côté TypeScript et le valueChanges ne s'exécute pas inutilement car le FormGroup est complet dès l'initialisation.
Angular nous permet de construire des formulaires complexes grâce à à de nombreuses fonctionnalités comme les Validators, l'accès au State d'un FormControl (invalid, touched, dirty, etc.) et l'abonnement aux modifications avec valueChanges.
Mais nous avons vu que la complexité augmentait drastiquement dès lors que l'on voulait utiliser les Template-Driven Forms pour les fonctionnalités avancées.
C'est pour cela que je vous conseille fortement d'utiliser uniquement les Reactive Forms, même dans vos formulaires les plus simples, pour avoir un code uniforme, simple et facile à maintenir.
Vous pouvez accéder au code des formulaires présentés au sein d'un projet Angular sur mon repo GitHub, juste ici : https://github.com/GaetanRouzies/angular-forms
Abonnez vous pour ne pas rater les nouveaux articles !
© Gaëtan Rouziès - Tous Droits Réservés