Les Reactive Forms restent entièrement pris en charge en v22 et demeurent les formulaires les plus utilisés dans les applications existantes. Depuis la v14, ils sont typés. Si vous avez un projet avec des Reactive Forms, inutile de tout migrer — Angular 22 propose des ponts (SignalFormControl, compatForm) pour combiner les deux mondes.
Formulaires typés
import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms';
// Les types sont déduits des valeurs initiales
const profile = new FormGroup({
name: new FormControl('', { nonNullable: true, validators: [Validators.required] }),
age: new FormControl<number | null>(null),
});
profile.value; // Partial<{ name: string; age: number | null }> — sans les contrôles désactivés
profile.getRawValue(); // { name: string; age: number | null } — avec tout
profile.controls.name.setValue(42); // ✗ erreur de compilation
// FormBuilder.nonNullable : chaque contrôle revient à sa valeur initiale lors de reset()
private readonly fb = inject(FormBuilder).nonNullable;
readonly form = this.fb.group({
name: ['', Validators.required],
email: ['', [Validators.required, Validators.email]],
skills: this.fb.array(['Angular']),
});getRawValue(): {
"name": "",
"email": "",
"security": {
"password": "",
"confirm": ""
},
"skills": [
"Angular",
"TypeScript"
]
}Validators
// Validator personnalisé (fonction pure)
export const noWhitespace: ValidatorFn = (c) => (c.value?.trim() ? null : { whitespace: true });
// Validator de groupe (multi-champs)
export const matchingPasswords: ValidatorFn = (group) =>
group.get('password')?.value !== group.get('confirm')?.value ? { mismatch: true } : null;
this.fb.group({ password: [''], confirm: [''] }, { validators: matchingPasswords });
// Validator asynchrone
export function usernameAvailable(api: UsersApi): AsyncValidatorFn {
return (c) => timer(400).pipe(switchMap(() => api.isAvailable(c.value)), map((ok) => (ok ? null : { taken: true })));
}Reactive Forms ↔ Signals
// Reactive Forms + Signals : toSignal sur des Observables
readonly value = toSignal(this.form.valueChanges, { initialValue: this.form.getRawValue() });
readonly status = toSignal(this.form.statusChanges, { initialValue: this.form.status });
readonly canSave = computed(() => this.status() === 'VALID');
// v18+ : un seul flux avec tous les événements (value, status, touched, pristine, submit, reset)
this.form.events.pipe(takeUntilDestroyed()).subscribe((e) => {
if (e instanceof FormSubmittedEvent) this.analytics.track('submit');
});Migration progressive vers les Signal Forms v22
// Migration progressive (v22) : deux directions
// 1. Bottom-up — un nouveau champ avec des règles Signal Forms dans un FormGroup existant
import { SignalFormControl } from '@angular/forms/signals/compat';
const form = new FormGroup({
name: new SignalFormControl('Ana', (p) => { required(p); }),
age: new FormControl(25),
});
// template: <input [formField]="nameControl.fieldTree" /> <input formControlName="age" />
// 2. Top-down — un Signal Form qui contient des contrôles réactifs existants
import { compatForm } from '@angular/forms/signals/compat';
const lastName = new FormControl('Pop');
const model = signal({ first: '', last: lastName });
const nameForm = compatForm(model, (p) => { required(p.first); });
nameForm.last().value(); // 'Pop' — la valeur, pas le FormControlClés dynamiques : FormRecord
FormGroup suppose que vous connaissez les clés à l’avance. Quand elles viennent du serveur (une liste de permissions, des réglages par module), utilisez FormRecord : toutes les valeurs ont le même type, et les clés peuvent être ajoutées ou retirées à l’exécution.
// les clés arrivent à l’exécution ; toutes les valeurs sont des booléens
readonly permissions = new FormRecord<FormControl<boolean>>({});
constructor() {
for (const p of ['read', 'write', 'delete']) {
this.permissions.addControl(p, new FormControl(false, { nonNullable: true }));
}
}
// la valeur est un objet avec les clés ajoutées
// this.permissions.value → { read: false, write: true, delete: false }Validateurs asynchrones et updateOn
Un validateur asynchrone renvoie un Observable ou une Promise avec les erreurs. Pendant son exécution, le contrôle a le statut PENDING et le formulaire n’est ni valide ni invalide. Mettez updateOn: 'blur' pour ne pas envoyer une requête à chaque frappe.
export function usernameFree(api: UsersApi): AsyncValidatorFn {
return (control) =>
api.exists(control.value).pipe(
map((taken) => (taken ? { taken: true } : null)),
catchError(() => of(null)), // serveur en panne ? ne bloquez pas le formulaire
);
}
readonly username = new FormControl('', {
nonNullable: true,
validators: [Validators.required],
asyncValidators: [usernameFree(inject(UsersApi))],
updateOn: 'blur', // valider à la sortie du champ, pas à chaque frappe
});
// template: @if (username.pending) { vérification… }Un seul flux pour tout : events
Au lieu de combiner valueChanges, statusChanges et vos propres signals pour touched ou submit, abonnez-vous à events. Vous recevez des événements typés pour la valeur, le statut, touched, pristine, submit et reset. Distinguez-les avec instanceof.
import { FormSubmittedEvent, StatusChangeEvent, TouchedChangeEvent, ValueChangeEvent } from '@angular/forms';
this.form.events.pipe(takeUntilDestroyed()).subscribe((e) => {
if (e instanceof ValueChangeEvent) this.saveDraft(e.value);
if (e instanceof StatusChangeEvent) this.status.set(e.status);
if (e instanceof TouchedChangeEvent) this.touched.set(e.touched);
if (e instanceof FormSubmittedEvent) this.track('submit'); // et FormResetEvent, PristineChangeEvent
});Reactive Forms ou Signal Forms ?
- Un projet existant avec des formulaires qui fonctionnent — ne les réécrivez pas sans raison.
- Des bibliothèques d’UI qui ne prennent en charge que
formControlName/ CVA. - Des flux complexes construits sur
valueChangeset des opérateurs RxJS.
- Nouveaux formulaires en v22.
- Beaucoup de logique conditionnelle (masqué / désactivé / obligatoire quand…).
- Vous voulez que le modèle soit un simple Signal, sans
patchValue.
La nouvelle approche, c’est Signal Forms — les bases ; un contrôle personnalisé qui marche dans les deux mondes : Contrôles de formulaire personnalisés.
Pièges fréquents
form.valueexclut les contrôles désactivés — utilisezgetRawValue()pour envoyer au serveur.- Sans
nonNullable,reset()metnull→ le type devientstring | null. - Abonnements à
valueChangessans nettoyage → fuite mémoire. UtiliseztoSignaloutakeUntilDestroyed. - Template-driven (ngModel) + Reactive sur le même élément → conflits. Choisissez-en un.
Aide-mémoire
new FormControl('', { nonNullable: true }) // reset() revient à la valeur initiale, pas à null
new FormGroup({ name: ..., email: ... }) // clés fixes
new FormArray<FormControl<string>>([]) // une liste de contrôles
new FormRecord<FormControl<boolean>>({}) // clés dynamiques, un seul type
form.getRawValue() // inclut aussi les contrôles désactivés
control.setValue(v) / patchValue(partial) / reset()
control.pending control.dirty control.touched
asyncValidators: [...] updateOn: 'blur' // validation serveur, au blur
form.events // valeur, statut, touched, pristine, submit, reset
toSignal(control.valueChanges, { initialValue: control.value })