Construisez des formulaires Angular maintenables avec Reactive Forms, validateurs réutilisables, erreurs UX propres et soumissions robustes.
Reactive Forms vs Template-driven
Angular propose deux approches pour les formulaires : les Template-driven forms
(pilotés par le template HTML via ngModel) et les Reactive Forms
(pilotés par le composant TypeScript via une API explicite). Comprendre quand utiliser
l'une ou l'autre est la première bonne pratique à maîtriser.
| Critere | Template-driven | Reactive Forms |
|---|---|---|
| Lisibilite initiale | Facile, peu de TypeScript | Plus verbeux, mais explicite |
| Testabilite | Difficile (lie au DOM) | Excellente (logique dans le composant) |
| Validation dynamique | Complexe a implementer | Triviale avec setValidators() |
| Typage TypeScript | Partiel | Complet avec typed forms |
| Etat du formulaire | Implicite dans le template | Explicite, observable, reactive |
| Formulaires imbriques | Fragile | Natif via FormGroup / FormArray |
Quand Template-driven suffit
- Formulaire de contact simple (3-4 champs, validation basique)
- Formulaire de recherche sans logique conditionnelle
- Prototype rapide ou POC
Quand Reactive Forms s'impose
- Validation conditionnelle (ex : champ obligatoire selon un autre champ)
- Formulaire avec champs dynamiques (ajout/suppression)
- Erreurs retournees par l'API a reinjecter dans le formulaire
- Formulaire en cours de test unitaire
- Workflow multi-etapes avec preservation de l'etat
FormGroup, FormControl et FormBuilder
Il existe deux façons de construire un formulaire reactif : instancier manuellement
FormGroup et FormControl, ou utiliser le service
FormBuilder qui est une surcouche plus concise. Les deux approches
sont strictement equivalentes.
Approche explicite (FormGroup + FormControl)
import { Component } from '@angular/core';
import { FormGroup, FormControl, Validators } from '@angular/forms';
@Component({ /* ... */ })
export class RegisterComponent {
// Construction explicite : lisible mais verbeux
form = new FormGroup({
firstName: new FormControl('', {
validators: [Validators.required, Validators.minLength(2)],
nonNullable: true // la valeur ne sera jamais null apres reset()
}),
email: new FormControl('', {
validators: [Validators.required, Validators.email],
nonNullable: true
}),
password: new FormControl('', {
validators: [Validators.required, Validators.minLength(8)],
nonNullable: true
})
});
}
Approche FormBuilder (recommandee)
import { Component, inject } from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms';
@Component({ /* ... */ })
export class RegisterComponent {
// inject() remplace constructor(private fb: FormBuilder)
private fb = inject(FormBuilder);
// fb.nonNullable garantit que tous les controles sont non-nullables
// c'est equivalent a nonNullable: true sur chaque FormControl
form = this.fb.nonNullable.group({
firstName: ['', [Validators.required, Validators.minLength(2)]],
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
role: ['user'] // valeur par defaut, pas de validation requise
});
}
Typage fort avec FormGroup<{...}>
Depuis Angular 14, les formulaires sont strictement types. TypeScript infere automatiquement le type de chaque controle a partir de la valeur initiale. Pour un typage encore plus explicite, on peut typer le FormGroup manuellement.
import { FormGroup, FormControl } from '@angular/forms';
// Interface du modele metier
interface RegisterForm {
firstName: FormControl<string>;
email: FormControl<string>;
password: FormControl<string>;
role: FormControl<'admin' | 'user' | 'viewer'>;
}
// Le type permet d'acceder a form.controls.email de facon typee
// TypeScript signale une erreur si on accede a un controle inexistant
export class RegisterComponent {
form: FormGroup<RegisterForm>;
constructor(private fb: FormBuilder) {
this.form = this.fb.nonNullable.group<RegisterForm>({
firstName: this.fb.nonNullable.control(''),
email: this.fb.nonNullable.control(''),
password: this.fb.nonNullable.control(''),
role: this.fb.nonNullable.control('user')
});
}
// form.getRawValue() retourne { firstName: string, email: string, ... }
// TypeScript connait exactement le type de chaque propriete
}
fb.nonNullable.group() est equivalent a
utiliser UntypedFormBuilder puis caster les types. Preferez
nonNullable dans tous les nouveaux formulaires — cela evite les
verifications value ?? '' dans les handlers de soumission.
Validateurs synchrones et asynchrones
Angular fournit un ensemble de validateurs built-in couvrant les cas courants.
Pour les regles metier specifiques, on cree des ValidatorFn personnalises.
Pour les verifications necessitant un appel reseau (email unique, SIRET valide...),
on utilise des AsyncValidatorFn.
Validateurs built-in les plus utiles
| Validateur | Description | Cle d'erreur |
|---|---|---|
Validators.required | Valeur non vide | required |
Validators.minLength(n) | Longueur minimale | minlength |
Validators.maxLength(n) | Longueur maximale | maxlength |
Validators.email | Format email basique | email |
Validators.pattern(rx) | Expression reguliere | pattern |
Validators.min(n) | Valeur numerique minimale | min |
Validators.max(n) | Valeur numerique maximale | max |
Validateur custom synchrone
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
// Un ValidatorFn prend un AbstractControl et retourne soit null (valide)
// soit un objet ValidationErrors avec une cle descriptive
export function passwordStrengthValidator(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const value = String(control.value ?? '');
// Criteres : au moins 8 chars, une majuscule, un chiffre, un special
const hasUpperCase = /[A-Z]/.test(value);
const hasDigit = /\d/.test(value);
const hasSpecial = /[!@#$%^&*(),.?":{}|<>]/.test(value);
const hasMinLength = value.length >= 8;
const isValid = hasUpperCase && hasDigit && hasSpecial && hasMinLength;
// Retourner null = valide, sinon un objet avec les criteres non respectes
return isValid ? null : {
passwordStrength: {
hasUpperCase,
hasDigit,
hasSpecial,
hasMinLength
}
};
};
}
// Usage dans le FormBuilder
// password: ['', [Validators.required, passwordStrengthValidator()]]
Validateur asynchrone (email unique)
import { AbstractControl, AsyncValidatorFn, ValidationErrors } from '@angular/forms';
import { inject } from '@angular/core';
import { Observable, of } from 'rxjs';
import { catchError, debounceTime, map, switchMap, take } from 'rxjs/operators';
import { UserService } from '../services/user.service';
// Factory function : retourne un AsyncValidatorFn
// On passe le service en parametre pour faciliter les tests
export function uniqueEmailValidator(userService: UserService): AsyncValidatorFn {
return (control: AbstractControl): Observable<ValidationErrors | null> => {
const email = String(control.value ?? '').trim();
// Ne pas appeler l'API si le champ est vide (laisser 'required' s'en charger)
if (!email) {
return of(null);
}
// debounceTime dans un AsyncValidator n'est pas recommande ici
// car le FormControl gere deja le delai via updateOn ou debounce global
return userService.checkEmailAvailability(email).pipe(
take(1), // important : completer l'Observable pour eviter les memory leaks
map(isAvailable => isAvailable ? null : { emailTaken: true }),
catchError(() => of(null)) // en cas d'erreur reseau : ne pas bloquer
);
};
}
// Usage dans le FormBuilder
// email: ['', [Validators.required, Validators.email],
// [uniqueEmailValidator(inject(UserService))]]
updateOn: 'blur' sur les
controles avec un validateur asynchrone pour eviter un appel API a chaque frappe.
this.fb.nonNullable.control('', { asyncValidators: [...], updateOn: 'blur' })
Validation cross-champs
Certaines regles de validation portent sur la relation entre plusieurs champs
(confirmation de mot de passe, coherence de dates, montant min/max).
Ces validateurs s'appliquent sur le FormGroup parent, pas sur
un FormControl isole.
Confirmer le mot de passe
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
// Ce validateur est applique sur le FormGroup contenant les deux champs
// Il compare password et confirmPassword et ajoute l'erreur sur confirmPassword
export function confirmPasswordValidator(): ValidatorFn {
return (group: AbstractControl): ValidationErrors | null => {
const password = group.get('password')?.value;
const confirmPassword = group.get('confirmPassword')?.value;
// Si les deux champs sont vides, ne pas valider ici
if (!password && !confirmPassword) {
return null;
}
// En cas de divergence, on pose l'erreur SUR le controle confirmPassword
// pour que le message apparaisse au bon endroit dans le template
if (password !== confirmPassword) {
group.get('confirmPassword')?.setErrors({ passwordMismatch: true });
return { passwordMismatch: true }; // egalement sur le groupe
}
// Sinon, effacer l'erreur si elle avait ete posee precedemment
const confirmControl = group.get('confirmPassword');
if (confirmControl?.hasError('passwordMismatch')) {
const errors = { ...confirmControl.errors };
delete errors['passwordMismatch'];
confirmControl.setErrors(Object.keys(errors).length ? errors : null);
}
return null;
};
}
// Application sur le groupe (pas sur un controle)
form = this.fb.nonNullable.group({
password: ['', [Validators.required, passwordStrengthValidator()]],
confirmPassword: ['', Validators.required]
}, {
validators: [confirmPasswordValidator()] // <-- sur le groupe
});
Date de fin posterieure a la date de debut
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
// Validateur de coherence temporelle : endDate doit etre apres startDate
export function dateRangeValidator(): ValidatorFn {
return (group: AbstractControl): ValidationErrors | null => {
const start = group.get('startDate')?.value;
const end = group.get('endDate')?.value;
// Si l'un des deux champs est vide, laisser 'required' traiter
if (!start || !end) {
return null;
}
const startDate = new Date(start);
const endDate = new Date(end);
// La date de fin doit etre strictement posterieure
if (endDate <= startDate) {
group.get('endDate')?.setErrors({ dateRange: true });
return { dateRange: true };
}
// Effacer l'erreur si les dates sont valides
const endControl = group.get('endDate');
if (endControl?.hasError('dateRange')) {
const errors = { ...endControl.errors };
delete errors['dateRange'];
endControl.setErrors(Object.keys(errors).length ? errors : null);
}
return null;
};
}
// Application
form = this.fb.nonNullable.group({
startDate: ['', Validators.required],
endDate: ['', Validators.required]
}, {
validators: [dateRangeValidator()]
});
setErrors(), le template peut afficher le message d'erreur
avec confirmPassword.hasError('passwordMismatch') sans logique
supplementaire.
FormArray : champs dynamiques
FormArray permet de gerer une liste de controles de longueur variable.
Cas d'usage typiques : plusieurs adresses, liste de competences, numeros de telephone,
lignes de commande dans un devis.
Composant TypeScript
import { Component, inject } from '@angular/core';
import { FormBuilder, FormArray, Validators } from '@angular/forms';
@Component({
selector: 'app-profile-form',
templateUrl: './profile-form.component.html'
})
export class ProfileFormComponent {
private fb = inject(FormBuilder);
form = this.fb.nonNullable.group({
name: ['', Validators.required],
// FormArray de FormControl<string>
phones: this.fb.nonNullable.array<string>([
// Au moins un champ par defaut
this.fb.nonNullable.control('', Validators.required)
])
});
// Getter de commodite pour eviter de caster dans le template
get phones(): FormArray {
return this.form.get('phones') as FormArray;
}
// Ajouter un nouveau champ telephone
addPhone(): void {
this.phones.push(
this.fb.nonNullable.control('', Validators.required)
);
}
// Supprimer un champ par son index
removePhone(index: number): void {
// Garder au minimum 1 champ
if (this.phones.length > 1) {
this.phones.removeAt(index);
}
}
submit(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
console.log(this.form.getRawValue());
// { name: 'Alice', phones: ['+33600000001', '+33600000002'] }
}
}
Template HTML avec @for
<!-- Template Angular 17+ avec @for (syntax de flux de controle moderne) -->
<form [formGroup]="form" (ngSubmit)="submit()">
<div class="mb-3">
<label class="form-label">Nom</label>
<input class="form-control" formControlName="name">
</div>
<!-- formArrayName connecte le tableau -->
<div formArrayName="phones">
@for (control of phones.controls; track $index) {
<div class="d-flex align-items-center mb-2">
<!-- L'index est utilise comme formControlName dans un FormArray -->
<input class="form-control mr-2"
[formControlName]="$index"
placeholder="Telephone {{ $index + 1 }}">
<button type="button"
class="btn btn-sm btn-outline-danger"
(click)="removePhone($index)"
[disabled]="phones.length === 1">
Supprimer
</button>
</div>
}
</div>
<button type="button" class="btn btn-outline-secondary mb-3" (click)="addPhone()">
+ Ajouter un telephone
</button>
<button type="submit" class="btn btn-primary">Enregistrer</button>
</form>
FormArray
de FormGroup pour des structures plus complexes
(ex: liste d'adresses avec rue, ville, code postal). Chaque
FormGroup dans l'array est un objet independant avec ses propres
validateurs.
Affichage des erreurs : strategie propre
Un mauvais affichage des erreurs degrade l'UX autant qu'une mauvaise validation. Les deux erreurs les plus frequentes : afficher les erreurs avant toute interaction (mur d'erreurs au chargement) ou ne pas les afficher du tout avant la soumission.
La regle : touched ou dirty
<!-- N'afficher l'erreur que si le champ a ete touche (blur) ou modifie -->
<div class="invalid-feedback d-block"
*ngIf="form.get('email')?.invalid &&
(form.get('email')?.touched || form.get('email')?.dirty)">
<span *ngIf="form.get('email')?.hasError('required')">
L'email est obligatoire.
</span>
<span *ngIf="form.get('email')?.hasError('email')">
Format d'email invalide.
</span>
<span *ngIf="form.get('email')?.hasError('emailTaken')">
Cet email est deja utilise.
</span>
</div>
Helper function pour DRY
// Dans le composant : helper qui evite de repeter la meme condition partout
hasError(controlName: string, errorCode: string): boolean {
const control = this.form.get(controlName);
// Afficher si le controle est invalide ET interagi (touched ou dirty)
return !!(
control?.hasError(errorCode) &&
(control.touched || control.dirty)
);
}
// Dans le template : plus concis
// <span *ngIf="hasError('email', 'required')">Email obligatoire.</span>
// <span *ngIf="hasError('email', 'email')">Format invalide.</span>
updateOn: 'blur' pour valider a la perte de focus
// Valider seulement quand l'utilisateur quitte le champ (pas a chaque frappe)
// Particulierement utile avec les validateurs asynchrones
form = this.fb.nonNullable.group({
email: this.fb.nonNullable.control('', {
validators: [Validators.required, Validators.email],
asyncValidators: [uniqueEmailValidator(this.userService)],
updateOn: 'blur' // <-- declenche validation au blur, pas au keystroke
})
});
markAllAsTouched() au submit
submit(): void {
// Si l'utilisateur clique sur "Envoyer" sans avoir touche les champs,
// forcer l'affichage de toutes les erreurs en une seule instruction
if (this.form.invalid) {
this.form.markAllAsTouched(); // touche tous les controles recursivement
return;
}
// ... suite de la soumission
}
ng-valid, ng-invalid, ng-touched,
ng-dirty sur chaque champ. On peut les styler directement en CSS
pour ajouter une bordure rouge/verte sans conditions ngIf supplementaires.
Reinjection des erreurs API dans le formulaire
Quand l'API retourne une erreur 422 (Unprocessable Entity) ou 409 (Conflict),
les erreurs doivent etre affichees dans le formulaire, pas uniquement dans
une alerte globale. Angular fournit setErrors() pour cela.
Erreur sur un controle specifique
// Scenario : l'API retourne { errors: { email: 'Cet email est deja enregistre.' } }
submit(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
this.loading.set(true);
this.authService.register(this.form.getRawValue()).subscribe({
next: () => {
this.loading.set(false);
this.router.navigateByUrl('/dashboard');
},
error: (err: HttpErrorResponse) => {
this.loading.set(false);
if (err.status === 422) {
// Reinjecter les erreurs champ par champ
const apiErrors = err.error?.errors ?? {};
Object.entries(apiErrors).forEach(([field, message]) => {
const control = this.form.get(field);
if (control) {
// setErrors() fusionne avec les erreurs existantes
control.setErrors({ serverError: message });
control.markAsTouched(); // forcer l'affichage immediat
}
});
} else {
// Erreur globale non liee a un champ
this.form.setErrors({ globalError: 'Une erreur est survenue. Reessayez.' });
}
}
});
}
Affichage dans le template
<!-- Erreur specifique a un champ (retournee par l'API) -->
<div class="invalid-feedback d-block"
*ngIf="form.get('email')?.hasError('serverError') && form.get('email')?.touched">
{{ form.get('email')?.getError('serverError') }}
</div>
<!-- Erreur globale du formulaire -->
<div class="alert alert-danger mt-3" *ngIf="form.hasError('globalError')">
{{ form.getError('globalError') }}
</div>
setErrors()
sont effacees automatiquement des que l'utilisateur modifie le champ
(si updateOn: 'change'). Pour un UX propre, effacez
explicitement les serverError dans le handler
valueChanges si vous utilisez updateOn: 'blur'.
Soumission robuste : etats et double-submit
Un formulaire de production doit gerer quatre etats distincts : pristine (non soumis), validating (validateurs asynchrones en cours), submitting (requete HTTP en vol) et success/error (reponse recue). Beaucoup de bugs de formulaires viennent de l'absence de gestion de ces etats.
Signal de chargement et protection double-submit
import { Component, inject, signal } from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { HttpErrorResponse } from '@angular/common/http';
import { AuthService } from '../services/auth.service';
@Component({ /* ... */ })
export class LoginComponent {
private fb = inject(FormBuilder);
private authService = inject(AuthService);
private router = inject(Router);
// Signal reactif pour l'etat de chargement
// Accessible dans le template sans AsyncPipe
loading = signal(false);
success = signal(false);
form = this.fb.nonNullable.group({
email: ['', [Validators.required, Validators.email]],
password: ['', Validators.required]
});
submit(): void {
// Guard 1 : formulaire invalide
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
// Guard 2 : eviter le double-submit si deja en cours
if (this.loading()) {
return;
}
this.loading.set(true);
// getRawValue() vs value :
// - value : exclut les controles disabled
// - getRawValue() : inclut TOUS les controles, meme disabled
// Toujours utiliser getRawValue() pour la soumission
const payload = this.form.getRawValue();
this.authService.login(payload).subscribe({
next: () => {
this.loading.set(false);
this.success.set(true);
this.router.navigateByUrl('/dashboard');
},
error: (err: HttpErrorResponse) => {
this.loading.set(false);
if (err.status === 401) {
// Reinjecter l'erreur sur le formulaire entier
this.form.setErrors({ invalidCredentials: true });
}
}
});
}
// Reset propre apres succes (si on reste sur la page)
resetForm(): void {
// reset() remet toutes les valeurs aux valeurs initiales
// et reinitialise les etats touched/dirty/pristine
this.form.reset();
this.success.set(false);
}
}
Template : bouton desactive pendant le chargement
<form [formGroup]="form" (ngSubmit)="submit()">
<!-- ... champs ... -->
<!-- Erreur d'identifiants incorrects -->
<div class="alert alert-danger" *ngIf="form.hasError('invalidCredentials')">
Email ou mot de passe incorrect.
</div>
<!-- Bouton desactive si chargement en cours OU si validateurs asynchrones pending -->
<button type="submit"
class="btn btn-primary btn-block"
[disabled]="loading() || form.pending">
<!-- Spinner visible pendant la requete -->
<span *ngIf="loading()"
class="spinner-border spinner-border-sm mr-2"
role="status"></span>
{{ loading() ? 'Connexion en cours...' : 'Se connecter' }}
</button>
</form>
true quand au moins
un validateur asynchrone est en cours d'execution. Desactiver le bouton
sur loading() || form.pending couvre a la fois le cas
"requete API en vol" et "validation asynchrone en cours".
Checklist Reactive Forms production
Avant de livrer un formulaire en production, verifiez chaque point de cette liste. Elle couvre les erreurs les plus frequentes observees dans les projets Angular réels.
- Utiliser
fb.nonNullable.group()pour eviter les valeursnullinattendues. - Externaliser chaque validateur metier dans une fonction nommee et testable.
- Appliquer les validateurs cross-champs sur le
FormGroupparent. - Ajouter
updateOn: 'blur'sur les champs avec validateurs asynchrones. - Afficher les erreurs uniquement si
touched || dirty. - Appeler
markAllAsTouched()avant tout retour anticipé au submit. - Utiliser
getRawValue()(et nonvalue) pour la soumission. - Proteger contre le double-submit avec un signal
loading. - Reinjecter les erreurs API via
control.setErrors({ serverError: ... }). - Desactiver le bouton submit sur
loading() || form.pending. - Tester les validateurs en isolation (pas besoin de TestBed).
Recapitulatif des validators les plus utiles
| Validateur | Cle d'erreur | Message recommande |
|---|---|---|
Validators.required |
required |
Ce champ est obligatoire. |
Validators.email |
email |
Veuillez saisir un email valide. |
Validators.minLength(n) |
minlength |
Minimum {n} caracteres requis. |
Validators.pattern(rx) |
pattern |
Format invalide. |
passwordStrengthValidator() |
passwordStrength |
8+ chars, majuscule, chiffre, special. |
confirmPasswordValidator() |
passwordMismatch |
Les mots de passe ne correspondent pas. |
uniqueEmailValidator() |
emailTaken |
Cet email est deja enregistre. |
setErrors({ serverError }) |
serverError |
Message retourne par l'API. |
Les Reactive Forms Angular sont l'un des outils les plus puissants du framework quand ils sont bien utilises. Une architecture propre — validateurs externalises, etats explicites, erreurs bien affichees — reduit drastiquement la dette technique et facilite les evolutions futures.