Les Reactive Forms en Angular sont un puissant outil qui permet de créer des formulaires dynamiques, complexes, scalables et surtout réactifs dans nos applications, notamment grâce à sa puissante intégration avec le concept d’Observable
. C’est pourquoi dans cet article, nous allons essayer de parcourir ensemble tous les aspects des Reactive Forms.
Concept des Reactive Forms
Les Reactive Forms se basent sur ces 3 instances :
FormControl
: pour définir un champ uniqueFormGroup
: pour définir un ensemble de champsFormArray
: pour définir dynamiquement un ensemble de champs
Chacune de ces instances est désignée pour être utilisée avec le concept d’Observable
, ce qui nous permet d’accéder à nos données de manière synchrone et de s’intégrer parfaitement à l’écosystème d’Angular, de plus chacune de ces instances utilise la classe de base AbstractControl
:
abstract class AbstractControl {
readonly valueChanges: Observable<TValue>;
readonly statusChanges: Observable<FormControlStatus>;
setValidators(validators: ValidatorFn | ValidatorFn[]): void;
setAsyncValidators(validators: AsyncValidatorFn | AsyncValidatorFn[]): void;
addValidators(validators: ValidatorFn | ValidatorFn[]): void;
addAsyncValidators(validators: AsyncValidatorFn | AsyncValidatorFn[]): void;
removeValidators(validators: ValidatorFn | ValidatorFn[]): void;
removeAsyncValidators(validators: AsyncValidatorFn | AsyncValidatorFn[]): void;
hasValidator(validator: ValidatorFn): boolean;
hasAsyncValidator(validator: AsyncValidatorFn): boolean;
clearValidators(): void;
clearAsyncValidators(): void;
disable(opts?: { onlySelf?: boolean; emitEvent?: boolean; }): void;
enable(opts?: { onlySelf?: boolean; emitEvent?: boolean; }): void;
abstract setValue(value: TRawValue, options?: Object): void;
abstract patchValue(value: TValue, options?: Object): void;
abstract reset(value?: TValue, options?: Object): void;
getRawValue(): any;
getError(errorCode: string, path?: string | (string | number)[]): any;
hasError(errorCode: string, path?: string | (string | number)[]): boolean;
}
C’est cette classe qui nous permettra d’interagir dynamiquement avec les champs présents dans nos formulaires, en plus de toute ces méthodes, nous avons également accès à l’état à un instant t d’un de nos champs AbstractControl
avec les propriétés suivantes :
abstract class AbstractControl {
readonly value: TValue;
readonly status: FormControlStatus;
readonly valid: boolean;
readonly invalid: boolean;
readonly pending: boolean;
readonly disabled: boolean;
readonly enabled: boolean;
readonly errors: ValidationErrors;
readonly pristine: boolean;
readonly dirty: boolean;
readonly touched: boolean;
readonly untouched: boolean;
}
Voici une liste récapitulative sur la signification de ces différentes propriétés :
valid
/invalid
: Le champ a passé toutes les fonctions de validation ou nonenabled
/disabled
: Le champ est actif ou nonpending
: Le champ est en train d’exécuter une fonction de validation asynchronepristine
/dirty
: La valeur du champ a changé ou nontouched
/untouched
: L’utilisateur a déclenché l’événement blur ou non
Avantages des Reactive Forms
Il y a plusieurs avantages à utiliser les Reactive Forms mais voici quelques point clés :
- Dissociation de la logique du template
- Permet de créer des formulaires complexes
- Validation de données complexe
- Fort couplage avec les Observable
- Moins verbeux
- Tests simplifiés
Mon premier formulaire : Form Control
Pour cela nous avoir besoin d’utiliser l’instance FormControl
pour déclarer unitairement un champ dans notre composant, voici le template de base que nous allons utiliser, les prochains exemples utiliseront ce même composant :
@Component({
standalone: true,
selector: "app-my-component",
templateUrl: "./my.component.html",
styleUrl: "./my.component.scss",
imports: [ReactiveFormsModule, JsonPipe, MatFormFieldModule, MatInputModule],
})
export class MyComponent {
readonly firstName = new FormControl("");
}
Et pour le code du template :
<form>
<mat-form-field>
<mat-label>First Name</mat-label>
<input matInput type="text" [formControl]="firstName">
</mat-form-field>
</form>
<pre>
{{ firstName.value | json }}
</pre>
Ce qui nous donne le résultat suivant :
C’est la directive formControl
qui permet à Reactive Form de faire le lien entre le DOM et l’instance AbstractControl
de notre champ :
Allons un peu plus loin dans ce premier exemple pour rajouter 2 boutons qui vont permettre de :
- Réinitialiser la valeur du champ
- Affecter la valeur « John » au champ
Pour cela, on va déclarer 2 nouvelles fonctions dans notre composant :
@Component()
export class MyComponent {
readonly firstName = new FormControl("");
reset(): void {
this.firstName.reset();
}
setValue(): void {
this.firstName.setValue("John");
}
}
Et insérer 2 nouveaux boutons dans notre template appelant les fonctions précédemment déclarées :
<form>
<mat-form-field>
<mat-label>Email</mat-label>
<input matInput formControlName="email" type="email">
</mat-form-field>
<button mat-raised-button color="primary" type="reset" (click)="reset()">
Reset
</button>
<button mat-raised-button color="primary" type="button" (click)="setValue()">
Set
</button>
</form>
Grouper plusieurs champs : FormGroup
Dans la majorité des cas, nos formulaires seront construits avec plusieurs champs, pour permettre cela, les Reactive Forms nous proposent 2 solutions :
FormGroup
: qui permet de définir un formulaire avec un nombre défini de champsFormArray
: qui permet de définir un formulaire dynamique avec un nombre de champs inconnus
Nous allons donc utiliser la classe FormGroup
puisque nous savons à l’avance quels seront nos champs associés dans notre formulaire. On déclarera l’instance FormGroup
avec le code suivant :
import { FormControl, FormGroup } from "@angular/forms";
@Component()
export class MyComponent {
readonly myFormGroup = new FormGroup({
firstName: new FormControl(""),
lastName: new FormControl(""),
});
}
En ce qui concernera notre template, on passera sa valeur dans la directive formGroup
qui permettra à Angular de declarer un AbstractControl
« parent »
<form [formGroup]="myFormGroup">
<mat-form-field>
<mat-label>First Name</mat-label>
<input matInput formControlName="firstName" type="text">
</mat-form-field>
<mat-form-field>
<mat-label>Last Name</mat-label>
<input matInput formControlName="lastName" type="text">
</mat-form-field>
</form>
<pre>
{{ myFormGroup.value | json }}
</pre>
Nous n’avons plus besoin de passer les instances de nos FormControl
à nos input via la directive formControl
, Angular
sera automatiquement capable de binder les instances grâce à la directive formControlName
, bien entendu pour que cela fonctionne, il faudra que le paramètre passé à cette directive soit présent dans notre instance de FormGroup
.
Dans le cas réel, nous aurons généralement besoin de récupérer les données du formulaire à la validation du formulaire (via un clique sur le bouton de soumission par exemple), pour cela on commence par définir une fonction pour notre traitement :
save(): void {
const { firstName, lastName } = this.myFormGroup.value;
console.log({ firstName, lastName });
}
Dans notre template, on déclarera dans notre balise form
, un bouton avec l’attribut type="submit"
avec l’évènement (ngSubmit)
pour déclarer la fonction qui sera appelé lors de la soumission du formulaire :
<form [formGroup]="myFormGroup" (ngSubmit)="save()">
<button mat-raised-button color="primary" type="submit">
Save
</button>
</form>
Attention néanmoins, on utilise l’évènement (ngSubmit
) car une application Angular est une Single Page Application et il faut éviter que la soumission du formulaire déclenche un évènement POST, cet évènement permets d’éviter ce comportement.
Déclarer plus efficacement un formulaire : FormBuilder
Déclarer plusieurs formulaires peut être répétitif et la syntaxe, avec les constructeurs des instances FormControl
et FormGroup
n’est pas très sexy et est lourde. Heureusement pour nous, Angular nous met à disposition un service FormBuilder
pour créer plus facilement et efficacement des formulaires.
Le service FormBuilder
dispose de trois méthodes : control()
, group()
et array()
. Il s’agit de méthode permettant de générer les instances associées dans vos composants.
Le code de notre précédent exemple se réfactorise comme ceci :
import { FormBuilder } from "@angular/forms";
@Component()
export class MyComponent {
private readonly formBuilder = inject(FormBuilder);
readonly myFormGroup = this.formBuilder.group({
firstName: "",
lastName: "",
});
}
C’est la syntaxe plébiscitée par la team Angular, je vous encourage donc à utiliser ce service partout dans vos applications !
Never Trust User : Validators
La validation des données dans un formulaire est une fonctionnalité vitale pour garantir que les données saisies par l’utilisateur soient corrects (bien sûr il faut également valider les données côté back),
Angular nous met à disposition une série de fonctions pour valider les champs de nos formulaires, toutes sont importées via la classe Validators
dans la dépendance @angular/forms
:
required
requiredTrue
email
min(value: number)
max(value: number)
minLength(value: number)
maxLength(value: number)
pattern(value: string | RegExp)
Pour ajouter ces fonctions de validation à nos champs, il faudra utiliser la syntaxe suivante côté composant :
import { Validators } from "@angular/forms";
@Component()
export class MyComponent {
readonly myFormGroup = this.formBuilder.group({
firstName: ["", [Validators.required]],
lastName: ["", [Validators.required, Validators.minLength(4)]],
});
}
Dans notre template, on pourra utiliser la propriété valid
ou invalid
de notre instance de FormGroup
pour vérifier que tous nos champs sont valides et désactiver le bouton de soumission en conséquence :
<form [formGroup]="myFormGroup" (ngSubmit)="save()">
<button mat-raised-button color="primary" type="submit" [disabled]="myFormGroup.invalid">
Save
</button>
</form>
Il est également intéressant de pouvoir afficher à l’utilisateur lorsqu’un champs est invalide et la raison, on pourra utiliser la fonction hasError()
pour cela :
<form [formGroup]="myFormGroup" (ngSubmit)="save()">
<mat-form-field>
<mat-label>First Name</mat-label>
<input matInput formControlName="firstName" type="text">
@if (myFormGroup.controls.firstName.hasError('required')) {
<mat-error>
This field is required
</mat-error>
}
</mat-form-field>
</form>
Never Trust User : Custom Validators
Dans certains cas, les fonctions de validation fournis par Angular ne sont pas suffisantes et nous devons créer des Customs Validators. Pour cela la dépendance @angular/forms
nous met à disposition 2 types pour nous aider à créer ces fonctions : ValidatorFn
et AsyncValidatorFn
.
Commençons par créer une fonction de validation synchrone (.i.e sans appel externe) qui vérifie si le nombre renseigné dans le champs est un nombre impair :
import { ValidatorFn } from "@angular/forms";
const shouldBeOdd = (): ValidatorFn => {
return (control) => {
if (control.value % 2 === 0) {
return { shouldBeOdd: true };
}
return null;
};
};
Puis on pourra l’utiliser comme n’importe quelle fonction de validation venant du framework :
@Component()
export class MyComponent {
private readonly formBuilder = inject(FormBuilder);
readonly myFormGroup = this.formBuilder.group({
number: ["", [Validators.required, shouldBeOdd()]],
});
}
Dans notre template, on pourra afficher le message grâce à la clé shouldBeOdd
:
<form [formGroup]="myFormGroup" (ngSubmit)="save()">
<mat-form-field>
<mat-label>Number</mat-label>
<input matInput formControlName="number" type="number">
@if (myFormGroup.controls.number.hasError('shouldBeOdd')) {
<mat-error>
This field should be odd
</mat-error>
}
</mat-form-field>
</form>
Asynchrone et Validation : Async Validator
Dans certains cas, nous devons appelé des services asynchrones pour valider la donnée de notre champ, pour cela on utilisera le type AsyncValidatorFn
, voici un exemple avec un appel HTTP pour valider qu’un email n’existe pas dans notre application :
const emailAlreadyTaken = (http: HttpClient): AsyncValidatorFn => {
return (control) => {
const params = new HttpParams().set("email", control.value);
return http.get<boolean>("api/emails", { params }).pipe(
map((exist) => {
if (exist) {
return { emailAlreadyTaken: true };
}
return null;
}),
catchError((err) => {
console.error(err);
return of({ error: true });
}),
);
};
};
On pourra utiliser à la fois des fonctions de validation synchrones et asynchrones sur le même champ, les fonctions de validation asynchrone ne se déclencheront que si toutes les fonctions de validation synchrones sont valides :
@Component()
export class MyComponent {
private readonly formBuilder = inject(FormBuilder);
private readonly http = inject(HttpClient);
readonly myFormGroup = this.formBuilder.group({
email: [
"",
[Validators.required, Validators.email],
[emailAlreadyTaken(this.http)],
],
});
}
Pour permettre à l’utilisateur d’indiquer qu’une validation asynchrone est en cours, on pourra utiliser la propriété pending
sur nos instances FormControl
et FormGroup
pour afficher un indicateur de chargement :
<form [formGroup]="myFormGroup" (ngSubmit)="save()">
<mat-form-field>
<mat-label>Email</mat-label>
<input matInput formControlName="email" type="email">
@if (myFormGroup.controls.email.hasError('required')) {
<mat-error>
This field is required
</mat-error>
}
@if (myFormGroup.controls.email.hasError('email')) {
<mat-error>
This field must be an email
</mat-error>
}
@if (myFormGroup.controls.email.hasError('emailAlreadyTaken')) {
<mat-error>
This email is already taken
</mat-error>
}
</mat-form-field>
@if (myFormGroup.controls.email.pending) {
<mat-spinner />
}
<button mat-raised-button color="primary" type="submit" [disabled]="myFormGroup.invalid || myFormGroup.pending">
Save
</button>
</form>
Réagir au changement : valueChanges
Chacune des instances FormControl
, FormGroup
et FormArray
possède une propriété valueChanges
renvoyant un Observable
avec la valeur de l’instance, nous pouvons donc souscrire à cet Observable
pour réagir au changement de cette valeur :
@Component()
export class MyComponent implements OnInit {
private readonly formBuilder = inject(FormBuilder);
readonly myFormGroup = this.formBuilder.group({
firstName: [""],
});
ngOnInit(): void {
this.myFormGroup.controls.firstName.valueChanges
.pipe(debounceTime(350), distinctUntilChanged())
.subscribe((value) => {
console.log(value);
});
}
}
Le principal avantage d’utiliser cette propriété (et donc d’un Observable
) est de pouvoir utiliser les opérateurs fournis avec la dépendance rxjs
comme map
, filter
, tap
ou debounceTime
, ce qui permet de créer des scénarios complexes pour écouter et interagir avec les changements dans notre formulaire.
Il est conseillé d’utiliser les opérateurs debounceTime
et distinctUntilChanged
pour éviter de réagir trop souvent au changement de notre formulaire.
Take away
Les Reactive Forms offrent une approche plus avancée et flexible pour la gestion des formulaires dans les applications Angular. Leur utilisation permet de simplifier le processus de développement, d’améliorer la qualité du code et d’offrir une meilleure expérience utilisateur en proposant des formulaires réactifs et dynamiques.
En comprenant les concepts clés et en utilisant efficacement les Reactive Forms, vous pouvez rendre la gestion des formulaires dans vos projets Angular plus fluide et plus robuste.
Pour retrouver nos autres articles, n’hésitez pas à faire un tour par ici