Logo Jetdev
Logo Jetdev
Edit Content

Angular Reactive Forms: Maîtriser la puissance des formulaires

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 unique
  • FormGroup: pour définir un ensemble de champs
  • FormArray: 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 :

TypeScript
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 :

TypeScript
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 non
  • enabled / disabled : Le champ est actif ou non
  • pending : Le champ est en train d’exécuter une fonction de validation asynchrone
  • pristine / dirty : La valeur du champ a changé ou non
  • touched / 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 :

TypeScript
@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 :

HTML
<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 :

TypeScript
@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 :

HTML
<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 champs
  • FormArray : 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 :

TypeScript
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 »

HTML
<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 :

TypeScript
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 :

HTML
<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 :

TypeScript
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 :

TypeScript
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 :

HTML
<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 :

HTML
<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 :

TypeScript
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 :

TypeScript
@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 :

HTML
<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 :

TypeScript
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 :

TypeScript
@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 :

HTML
<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 :

TypeScript
@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

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *