Skip to content

Reactive Forms — Deep dive (typed, dynamic, signals)

TL;DR — Les Reactive Forms d'Angular modélisent un formulaire comme un arbre d'objets typés (FormControl, FormGroup, FormArray) déclarés en TypeScript, séparés du template. Depuis Angular 14, les formulaires sont strictement typés : chaque contrôle porte son type de valeur, les nullables sont explicites, et NonNullableFormBuilder supprime le T | null parasite. On combine valueChanges / statusChanges (Observables) avec updateOn pour piloter la fréquence des recalculs, on compose des validators synchrones et asynchrones, et on délègue l'affichage des erreurs à un composant dédié via content projection. Angular 19+ amorce l'arrivée de signal forms (preview), qui remplaceront à terme la couche Observable par des signaux primaires — sans changer le mental model.


🧠 Mental model — ASCII + analogie

Un Reactive Form est un arbre de contrôles miroir du JSON métier. Le template ne contient que des "prises" (formControlName) branchées sur ce modèle. La source de vérité vit dans la classe.

                ┌─────────────────────────────────────┐
                │            FormGroup<User>          │
                │                                     │
   value ──────►│  email: FormControl<string>         │◄──── valueChanges$
   status ─────►│  password: FormControl<string>      │◄──── statusChanges$
                │  addresses: FormArray<...>          │
                │   ├─ FormGroup<Address> [0]         │
                │   │    street: FormControl<string>  │
                │   │    city:   FormControl<string>  │
                │   └─ FormGroup<Address> [1]         │
                │                                     │
                └─────────────────────────────────────┘
                          ▲                   ▲
                          │                   │
                  formGroup directive   formControlName
                          │                   │
                          └─── HTML template ─┘

Analogie : le tableau de bord d'un avion. Le pilote (le composant) lit les instruments (value, status, dirty, touched) et envoie des commandes (setValue, patchValue, disable). Les hublots (le template) affichent l'état mais ne sont pas l'avion. Avec les template-driven forms, on construit l'avion à partir des hublots, ce qui fonctionne pour un planeur, pas pour un long-courrier.


🛠️ Code minimal (ts + html)

Un formulaire d'inscription typé, avec validators sync et async, et un FormArray d'adresses.

ts
import { Component, inject } from '@angular/core';
import {
  AbstractControl,
  AsyncValidatorFn,
  FormArray,
  FormControl,
  FormGroup,
  NonNullableFormBuilder,
  ReactiveFormsModule,
  ValidationErrors,
  Validators,
} from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { debounceTime, map, of, switchMap } from 'rxjs';

interface AddressForm {
  street: FormControl<string>;
  city: FormControl<string>;
  zip: FormControl<string>;
}

interface SignupForm {
  email: FormControl<string>;
  password: FormControl<string>;
  confirm: FormControl<string>;
  addresses: FormArray<FormGroup<AddressForm>>;
  newsletter: FormControl<boolean>;
}

@Component({
  selector: 'app-signup',
  standalone: true,
  imports: [ReactiveFormsModule],
  templateUrl: './signup.component.html',
})
export class SignupComponent {
  private fb = inject(NonNullableFormBuilder);
  private http = inject(HttpClient);

  form: FormGroup<SignupForm> = this.fb.group(
    {
      email: this.fb.control('', {
        validators: [Validators.required, Validators.email],
        asyncValidators: [this.emailTakenValidator()],
        updateOn: 'blur',
      }),
      password: this.fb.control('', [Validators.required, Validators.minLength(10)]),
      confirm: this.fb.control('', Validators.required),
      addresses: this.fb.array<FormGroup<AddressForm>>([this.buildAddress()]),
      newsletter: this.fb.control(true),
    },
    { validators: [this.passwordsMatch] },
  );

  get addresses(): FormArray<FormGroup<AddressForm>> {
    return this.form.controls.addresses;
  }

  addAddress(): void {
    this.addresses.push(this.buildAddress());
  }

  removeAddress(index: number): void {
    this.addresses.removeAt(index);
  }

  submit(): void {
    if (this.form.invalid) {
      this.form.markAllAsTouched();
      return;
    }
    const payload = this.form.getRawValue(); // entièrement typé, non nullable
    this.http.post('/api/users', payload).subscribe();
  }

  private buildAddress(): FormGroup<AddressForm> {
    return this.fb.group({
      street: this.fb.control('', Validators.required),
      city: this.fb.control('', Validators.required),
      zip: this.fb.control('', [Validators.required, Validators.pattern(/^\d{5}$/)]),
    });
  }

  private passwordsMatch(group: AbstractControl): ValidationErrors | null {
    const pwd = group.get('password')?.value;
    const confirm = group.get('confirm')?.value;
    return pwd && confirm && pwd !== confirm ? { passwordMismatch: true } : null;
  }

  private emailTakenValidator(): AsyncValidatorFn {
    return (control) =>
      of(control.value).pipe(
        debounceTime(300),
        switchMap((email) =>
          this.http.get<{ taken: boolean }>(`/api/users/check?email=${email}`),
        ),
        map((res) => (res.taken ? { emailTaken: true } : null)),
      );
  }
}

Et le template, volontairement sec et lisible.

html
<form [formGroup]="form" (ngSubmit)="submit()" novalidate>
  <label>
    Email
    <input type="email" formControlName="email" autocomplete="email" />
    @if (form.controls.email.touched && form.controls.email.errors; as errs) {
      <small class="error">
        @if (errs['required']) { L'email est requis. }
        @if (errs['email']) { Format invalide. }
        @if (errs['emailTaken']) { Cet email est déjà utilisé. }
      </small>
    }
  </label>

  <label>
    Mot de passe
    <input type="password" formControlName="password" />
  </label>

  <label>
    Confirmation
    <input type="password" formControlName="confirm" />
  </label>
  @if (form.errors?.['passwordMismatch'] && form.controls.confirm.touched) {
    <small class="error">Les mots de passe ne correspondent pas.</small>
  }

  <fieldset formArrayName="addresses">
    <legend>Adresses</legend>
    @for (addr of addresses.controls; track $index) {
      <div [formGroupName]="$index">
        <input formControlName="street" placeholder="Rue" />
        <input formControlName="city" placeholder="Ville" />
        <input formControlName="zip" placeholder="Code postal" />
        <button type="button" (click)="removeAddress($index)">Supprimer</button>
      </div>
    }
    <button type="button" (click)="addAddress()">+ Ajouter une adresse</button>
  </fieldset>

  <label>
    <input type="checkbox" formControlName="newsletter" />
    Recevoir la newsletter
  </label>

  <button type="submit" [disabled]="form.invalid || form.pending">S'inscrire</button>
</form>

🎯 Patterns courants

Typed forms et NonNullableFormBuilder

Depuis Angular 14, FormControl<string> est distinct de FormControl<string | null>. Par défaut, new FormControl('') retourne FormControl<string | null> parce que reset() repose la valeur à null. On a deux solutions complémentaires : déclarer chaque contrôle avec nonNullable: true (new FormControl('', { nonNullable: true })), ou utiliser NonNullableFormBuilder qui applique cette option à tous les contrôles créés via .control() et .group(). Dans une base de code moderne, on injecte presque systématiquement NonNullableFormBuilder — quitte à passer ponctuellement par FormBuilder quand on veut explicitement modéliser un champ optionnel comme T | null.

Le bénéfice est immédiat dans le code applicatif : this.form.getRawValue() retourne un objet entièrement typé sans null, prêt à être envoyé à l'API sans coercion ni ! partout.

FormBuilder vs construction manuelle

FormBuilder.group({ ... }) est purement syntaxique : il évite les new FormControl(...) et new FormGroup(...) répétitifs. Le tradeoff est que l'inférence de type des group factories est parfois moins précise que la construction manuelle quand on imbrique des FormArray. En pratique, on déclare une interface explicite (SignupForm ci-dessus) et on type le FormGroup<SignupForm> au site de déclaration — l'inférence interne suit.

valueChanges et statusChanges

Chaque AbstractControl expose deux Observables : valueChanges (à chaque mutation de valeur) et statusChanges (à chaque transition VALID / INVALID / PENDING / DISABLED). On les utilise pour brancher des side-effects réactifs : recalculer un total, déclencher une auto-save, dériver un autre contrôle.

ts
this.form.controls.zip.valueChanges
  .pipe(
    debounceTime(250),
    distinctUntilChanged(),
    switchMap((zip) => this.geo.lookupCity(zip)),
    takeUntilDestroyed(),
  )
  .subscribe((city) => this.form.controls.city.setValue(city, { emitEvent: false }));

Deux pièges classiques : (1) la boucle infinie quand un setValue réémet sur valueChanges — on passe { emitEvent: false } pour court-circuiter ; (2) la souscription qui fuit — on utilise takeUntilDestroyed() (Angular 16+) ou un Subject destroy$ manuel.

updateOn: 'blur' | 'change' | 'submit'

Par défaut, Angular recalcule valeur et statut à chaque frappe. C'est bien pour un toggle, c'est catastrophique pour un champ avec async validator qui appelle l'API. updateOn: 'blur' ne déclenche le recalcul qu'à la perte du focus ; updateOn: 'submit' ne le fait qu'à la soumission. On configure soit au niveau du contrôle, soit du group (la stratégie du group sert de défaut pour les enfants qui n'en spécifient pas).

ts
this.fb.group(
  { email: this.fb.control('', { validators: Validators.email, updateOn: 'blur' }) },
  { updateOn: 'change' },
);

Nested forms et FormArray dynamiques

Le FormArray est l'outil de prédilection pour les listes : adresses, lignes de facture, tags. On expose un getter typé (get addresses()) et on manipule via push(), removeAt(), clear(), insert(). Côté template, on combine formArrayName="addresses" et [formGroupName]="$index" à l'intérieur d'un @for. On track sur $index quand l'ordre est stable, ou sur un id métier si on réordonne.

Pour les sous-formulaires complexes, on extrait un composant enfant qui reçoit le FormGroup en @Input() et l'expose via ControlContainer :

ts
@Component({
  selector: 'app-address',
  standalone: true,
  imports: [ReactiveFormsModule],
  viewProviders: [{ provide: ControlContainer, useExisting: FormGroupDirective }],
  template: `
    <ng-container [formGroupName]="groupName">
      <input formControlName="street" />
      <input formControlName="city" />
    </ng-container>
  `,
})
export class AddressComponent {
  @Input({ required: true }) groupName!: string | number;
}

Le viewProviders est la clé : il fait remonter le ControlContainer du parent pour que formGroupName fonctionne dans l'enfant.

États de contrôle : dirty, touched, pristine, untouched

Ces flags décrivent l'interaction utilisateur, pas la validité :

FlagSignification
pristine / dirtyla valeur a été modifiée par l'utilisateur (programmatique : non).
untouched / touchedle champ a perdu le focus au moins une fois.
pendingun async validator est en cours.
valid / invaliddérivé des validators.

La règle d'or : n'affichez les erreurs qu'après touched ou après soumission. Sinon, l'utilisateur voit "champ requis" dès l'ouverture du formulaire, ce qui est anxiogène.

setValue vs patchValue

setValue(obj) exige un objet complet correspondant à toute la structure du group ; toute clé manquante déclenche une erreur. patchValue(obj) accepte un objet partiel et n'écrase que les contrôles présents. En pratique : setValue pour la réinitialisation explicite ou le binding initial à partir d'une réponse API complète, patchValue pour les updates partiels (notamment dans un autosave ou lors d'un select-and-fill).

ts
// Hydrate depuis la réponse API — on a la garantie d'avoir toutes les clés
this.form.setValue(response.user);

// Update partiel après une géolocalisation
this.form.patchValue({ addresses: [{ city: detectedCity }] });

Subtilité avec les FormArray : patchValue([...]) n'ajoute pas de contrôles, il met à jour les éléments existants par index. Si le tableau cible est plus long que le FormArray, les éléments en trop sont ignorés. Pour ajuster la taille dynamiquement, on clear() puis push() ou on removeAt() selon la diff.

reset() et la valeur initiale

form.reset() sans argument remet toutes les valeurs à null — sauf si le contrôle a été déclaré avec nonNullable: true, auquel cas il revient à la valeur initiale fournie à la construction. C'est l'une des raisons majeures pour lesquelles NonNullableFormBuilder est devenu le défaut : on évite de devoir passer un objet initialValue complet à chaque reset.

ts
const ctrl = new FormControl('default', { nonNullable: true });
ctrl.setValue('something else');
ctrl.reset();             // -> 'default' (pas null)

reset(value) accepte un objet partiel et reset les autres contrôles à leur valeur initiale.

Disabling programmatique

On ne doit pas utiliser l'attribut HTML disabled sur un input lié à un formControlName — Angular logge un warning explicite. La bonne approche est control.disable() et control.enable() :

ts
this.form.controls.shippingAddress.disable();   // exclu de form.value
this.form.controls.shippingAddress.enable();    // réinclus

Important : un contrôle disabled est retiré de form.value mais reste dans form.getRawValue(). C'est le levier pour gérer des champs "calculés" (TVA dérivée du total HT) qu'on affiche grisés mais qu'on envoie quand même au serveur.

Auto-save et valueChanges débouncé

Pattern fréquent : sauvegarder le formulaire au fil de l'eau sans bouton submit. Le squelette :

ts
constructor() {
  this.form.valueChanges
    .pipe(
      debounceTime(800),
      filter(() => this.form.valid),
      distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)),
      switchMap((value) => this.api.autosave(value)),
      takeUntilDestroyed(),
    )
    .subscribe();
}

Trois précautions : (1) le filter sur valid évite d'envoyer un état intermédiaire ; (2) le distinctUntilChanged évite les sauvegardes inutiles quand la valeur revient à un état antérieur ; (3) switchMap annule la requête précédente si une nouvelle valeur arrive.

Composant d'affichage d'erreurs

On factorise l'affichage des erreurs dans un composant réutilisable avec content projection pour les messages personnalisés.

ts
@Component({
  selector: 'app-control-error',
  standalone: true,
  imports: [],
  template: `
    @if (shouldShow()) {
      <small class="error">
        <ng-content></ng-content>
      </small>
    }
  `,
})
export class ControlErrorComponent {
  @Input({ required: true }) control!: AbstractControl;
  @Input() errorKey!: string;

  shouldShow(): boolean {
    return (
      this.control.touched &&
      this.control.errors?.[this.errorKey] !== undefined &&
      this.control.errors[this.errorKey] !== null
    );
  }
}

Utilisation :

html
<input formControlName="email" />
<app-control-error [control]="form.controls.email" errorKey="required">
  L'email est requis.
</app-control-error>
<app-control-error [control]="form.controls.email" errorKey="email">
  Format invalide.
</app-control-error>

On peut pousser plus loin avec une map d'erreurs centralisée ({ required: 'Champ requis', email: 'Format invalide' }) injectée via un token, mais le pattern ci-dessus est un excellent point de départ : peu de code, beaucoup de réutilisation, messages i18n-friendly.

Intégration avec les Signals (Angular 17+)

toSignal(form.valueChanges) permet de matérialiser un Observable de formulaire en signal :

ts
readonly value = toSignal(this.form.valueChanges, { initialValue: this.form.getRawValue() });
readonly canSubmit = computed(() => this.form.valid && this.value().email.length > 0);

À ce stade, la couche Forms reste Observable-first, mais la consommation en signaux est triviale. C'est typiquement la voie de migration progressive avant l'arrivée des signal forms.

Signal forms (expérimental, Angular 21+)

Attention au piège mental : les signal forms ne sont PAS une évolution des Reactive Forms, ce sont une réécriture from scratch. Le mental model bascule. Avec les Reactive Forms, la source de vérité est l'arbre de contrôles (FormGroup), et la valeur est un dérivé. Avec les signal forms, la source de vérité est un WritableSignal du modèle métier (un POJO), et le formulaire (FieldTree) est une vue qui l'enrobe avec validation, état et soumission. On ne déclare plus de FormControl ; on déclare le modèle, et form() le réfléchit.

L'API (marquée @experimental en 21.0.0, visée stable en 22) :

ts
import { Component, signal } from '@angular/core';
import { form, Control, required, email, minLength } from '@angular/forms/signals';

@Component({
  selector: 'app-signup',
  standalone: true,
  imports: [Control],
  template: `
    <form (submit)="$event.preventDefault()">
      <input [control]="f.email" />
      @if (f.email().touched() && f.email().errors().length) {
        <small class="error">Email invalide.</small>
      }
      <input type="password" [control]="f.password" />
      <button [disabled]="f().invalid()">S'inscrire</button>
    </form>
  `,
})
export class SignupComponent {
  // 1) la source de vérité = un signal du modèle, pas un arbre de contrôles
  readonly model = signal({ email: '', password: '' });

  // 2) form() enrobe le signal + un schéma de validation par chemin
  readonly f = form(this.model, (path) => {
    required(path.email, { message: 'Email requis' });
    email(path.email);
    required(path.password);
    minLength(path.password, 10);
  });

  submit(): void {
    if (this.f().invalid()) return;
    // la valeur courante EST le signal modèle, déjà typé sans null
    this.api.post(this.model());
  }
}

Trois changements structurants qu'un staff doit savoir nommer :

  • Tout est signal. f().valid(), f().invalid(), f().touched(), f.email().errors() sont des signaux — donc lisibles directement dans le template et dans un computed() sans toSignal. La propagation est pull-based : pas de valueChanges Observable, pas de souscription à nettoyer.
  • La directive est [control] (et non formControlName). On branche un champ du FieldTree directement : [control]="f.email". Pas de formGroup/formControlName parent-enfant à câbler.
  • La validation est un schéma fonctionnel déclaré par chemin (required(path.email, …)), pas une liste de Validators attachée à chaque contrôle. C'est composable et réutilisable comme un schéma Zod.

Interop : compatForm / SignalFormControl permettent de faire cohabiter un FormControl/FormGroup classique avec un signal form pendant une migration. Discipline 2026 : on n'utilise PAS les signal forms en production (@experimental, API qui peut bouger jusqu'à la 22). On les explore dans un module pilote isolé. Toute la suite de ce document — et 100 % du code de production — reste sur les Reactive Forms classiques, qui sont GA, supportés, et la recommandation par défaut.

form.events — le flux unifié (Angular 18+)

Historiquement, pour réagir à tout ce qui change dans un form (valeur et statut et touched et pristine), il fallait merger trois Observables hétérogènes. Angular 18 introduit control.events, un flux unique et typé par discriminated union :

ts
import {
  ValueChangeEvent,
  StatusChangeEvent,
  TouchedChangeEvent,
  PristineChangeEvent,
  FormSubmittedEvent,
} from '@angular/forms';

this.form.events.pipe(takeUntilDestroyed()).subscribe((event) => {
  if (event instanceof ValueChangeEvent) {
    // event.value est typé selon le form
    this.analytics.fieldChanged(event.source, event.value);
  } else if (event instanceof TouchedChangeEvent && event.touched) {
    this.markFieldVisited(event.source);
  } else if (event instanceof FormSubmittedEvent) {
    this.trackSubmit();
  }
});

Pourquoi c'est un gain staff-level : un seul subscribe, un seul cycle de vie à nettoyer, et le instanceof raffine le type — pas de cast. C'est le bon endroit pour brancher de l'observabilité (analytics de remplissage, time-to-first-error, abandon de formulaire) sans polluer chaque contrôle. Le mental model à retenir : valueChanges/statusChanges sont des projections de events. Pour un side-effect ciblé sur une seule dimension, gardez valueChanges (plus simple) ; pour un observateur transverse, prenez events.

Accessibilité — l'angle mort le plus fréquent

Un formulaire « valide techniquement » mais inaccessible est un bug de production. Trois leviers non négociables :

  1. Lier l'erreur au champ via aria-describedby + id, et signaler l'état via aria-invalid.
  2. Annoncer dynamiquement les erreurs avec un role="alert" (ou aria-live="assertive") — sinon un lecteur d'écran ne « voit » pas l'erreur apparaître.
  3. Focus management : au submit invalide, déplacer le focus sur le premier champ en erreur, pas juste afficher du rouge.
html
<label for="email">Email</label>
<input
  id="email"
  type="email"
  formControlName="email"
  [attr.aria-invalid]="form.controls.email.touched && form.controls.email.invalid"
  aria-describedby="email-err"
/>
@if (form.controls.email.touched && form.controls.email.errors; as errs) {
  <small id="email-err" class="error" role="alert">
    @if (errs['required']) { L'email est requis. }
    @if (errs['email']) { Format invalide. }
  </small>
}
ts
// Focus sur la première erreur au submit invalide
submit(): void {
  if (this.form.invalid) {
    this.form.markAllAsTouched();
    queueMicrotask(() => {
      const firstInvalid = this.host.nativeElement.querySelector(
        '[aria-invalid="true"], .ng-invalid[formControlName]',
      ) as HTMLElement | null;
      firstInvalid?.focus();
      firstInvalid?.scrollIntoView({ block: 'center', behavior: 'smooth' });
    });
    return;
  }
  // …
}

queueMicrotask (ou afterNextRender en zoneless) laisse Angular peindre les aria-invalid avant qu'on aille chercher le DOM. Un staff engineer factorise ces trois leviers dans le composant ControlErrorComponent montré plus haut, pour qu'aucun champ de l'app ne puisse être inaccessible « par oubli ».

Performance & change detection

Reactive Forms et OnPush cohabitent très bien : les directives formControlName/formGroup marquent la vue dirty quand le modèle change, donc pas besoin de markForCheck() manuel. Les vrais coûts arrivent ailleurs :

CoûtSymptômeRemède
Validators lourds à chaque frappeUI qui rame sur gros formupdateOn: 'blur', mémoïser le validator, sortir le calcul coûteux dans un valueChanges débouncé
FormArray géant (centaines de lignes)submit/patch lents, DOM lourdvirtualisation (cdk-virtual-scroll), pagination logique, ne pas tout rendre
JSON.stringify dans distinctUntilChanged d'un gros formGC pressure, jankcomparer un sous-ensemble de clés, ou hasher
getRawValue() appelé dans un getter de templaterecalcul à chaque CDexposer un computed/toSignal, jamais d'appel de méthode dans le template chaud

Règle staff : rien dans value/getRawValue()/valid ne doit être appelé comme méthode dans un template sur un form non trivial — on matérialise en signal (toSignal) et on lit le signal, ce qui mémoïse et évite le recalcul à chaque cycle de change detection.


🤖 Forms et IA — assistance de saisie streamée (Angular)

Cas réel sur la stack du learner : un agent LLM pré-remplit ou corrige un formulaire (extraction depuis un document collé, suggestion de classification, reformulation d'un champ texte). Le défi UI : faire arriver des tokens en streaming dans des FormControl sans casser l'état dirty/touched, sans boucle valueChanges, et avec un bouton Stop qui annule client et serveur.

Streaming des suggestions dans un FormControl (signals + fetch reader, zoneless-safe)

On consomme un endpoint NestJS SSE (/api/ai/fill) qui émet des tokens, on accumule dans un buffer append-only, et on coalesce les écritures dans le FormControl via requestAnimationFrame pour ne pas déclencher un cycle de change detection par token.

ts
import { Component, ElementRef, inject, signal } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';

type AiState =
  | { kind: 'idle' }
  | { kind: 'streaming'; tokens: number }
  | { kind: 'done' }
  | { kind: 'error'; message: string }
  | { kind: 'aborted' };

@Component({
  selector: 'app-ai-summary-field',
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <label>Résumé (assisté)
      <textarea formControlName="summary" rows="6"></textarea>
    </label>
    @switch (state().kind) {
      @case ('streaming') { <button type="button" (click)="stop()">Stop</button> }
      @default { <button type="button" (click)="suggest()">Suggérer</button> }
    }
    @if (state().kind === 'error') { <small class="error" role="alert">Échec IA.</small> }
  `,
})
export class AiSummaryFieldComponent {
  private readonly host = inject(ElementRef<HTMLElement>);
  readonly summary = new FormControl('', { nonNullable: true });
  readonly state = signal<AiState>({ kind: 'idle' });

  private controller?: AbortController;
  private buffer = '';
  private rafScheduled = false;

  async suggest(): Promise<void> {
    this.controller = new AbortController();
    this.buffer = this.summary.value;
    this.state.set({ kind: 'streaming', tokens: 0 });

    try {
      const res = await fetch('/api/ai/fill', {
        method: 'POST',
        headers: { 'content-type': 'application/json', accept: 'text/event-stream' },
        body: JSON.stringify({ field: 'summary', context: this.collectContext() }),
        signal: this.controller.signal,
      });
      if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);

      const reader = res.body.getReader();
      const decoder = new TextDecoder();
      let tokens = 0;

      while (true) {
        const { value, done } = await reader.read();
        if (done) break;
        for (const token of this.parseSse(decoder.decode(value, { stream: true }))) {
          this.buffer += token;
          tokens++;
          this.scheduleFlush();
        }
        this.state.set({ kind: 'streaming', tokens });
      }
      this.flush();
      this.state.set({ kind: 'done' });
    } catch (err) {
      if ((err as Error).name === 'AbortError') {
        this.flush();
        this.state.set({ kind: 'aborted' });
      } else {
        this.state.set({ kind: 'error', message: (err as Error).message });
      }
    } finally {
      this.controller = undefined;
    }
  }

  stop(): void {
    this.controller?.abort(); // annule le fetch ET, côté serveur, déclenche req.on('close')
  }

  /** Coalesce les écritures : une seule maj du FormControl par frame. */
  private scheduleFlush(): void {
    if (this.rafScheduled) return;
    this.rafScheduled = true;
    requestAnimationFrame(() => {
      this.rafScheduled = false;
      this.flush();
    });
  }

  private flush(): void {
    // emitEvent:false -> pas de boucle valueChanges ; on ne "touche" pas le champ programmatiquement
    this.summary.setValue(this.buffer, { emitEvent: false });
    this.summary.markAsDirty(); // c'est bien une modification de contenu : on la matérialise
  }

  private *parseSse(chunk: string): Iterable<string> {
    for (const line of chunk.split('\n')) {
      if (line.startsWith('data: ')) {
        const data = line.slice(6);
        if (data && data !== '[DONE]') yield JSON.parse(data).delta ?? '';
      }
    }
  }

  private collectContext(): unknown {
    return {}; // autres champs du form, document collé, etc.
  }
}

Points senior qui font la différence :

  • emitEvent: false à chaque token : sinon chaque écriture re-déclenche valueChanges → validators → potentielle boucle si un effect ré-écrit. On flush la valeur sans bruit, et on émet une fois à la fin si un consommateur en a besoin.
  • rAF-coalescing : sous zoneless, écrire 400 fois dans un FormControl = 400 cycles potentiels. Une écriture par frame plafonne le coût à ~60 maj/s, perceptuellement « live » sans jank.
  • markAsDirty() mais pas markAsTouched() : le contenu a changé (dirty = vrai), mais l'utilisateur n'a pas « visité » le champ. Garder touched piloté par l'humain préserve la sémantique d'affichage d'erreurs.
  • Stop = double annulation : controller.abort() coupe le fetch côté client et fait remonter une déconnexion TCP que le serveur NestJS écoute (req.on('close') / AbortSignal passé au SDK) pour arrêter de brûler des tokens. Annuler seulement côté client laisse le serveur générer (et facturer) dans le vide.

Trace agentique : timeline de tool-calls (discriminated union)

Quand l'IA ne fait pas que remplir un champ mais orchestre des outils (« cherche le SIREN », « valide l'IBAN », « extrait l'adresse »), on rend une timeline. Chaque étape est un état discriminé, exactement comme un FormControl a un status :

ts
type ToolStep =
  | { id: string; tool: string; phase: 'pending' }
  | { id: string; tool: string; phase: 'running' }
  | { id: string; tool: string; phase: 'streaming'; partial: string }
  | { id: string; tool: string; phase: 'done'; result: unknown }
  | { id: string; tool: string; phase: 'error'; error: string };

readonly steps = signal<ToolStep[]>([]);

// Mise à jour immuable par id (append-only puis patch ciblé)
private patchStep(id: string, next: Partial<ToolStep>): void {
  this.steps.update((list) =>
    list.map((s) => (s.id === id ? ({ ...s, ...next } as ToolStep) : s)),
  );
}

Le résultat d'un outil (phase: 'done') peut alors patcher le form : this.form.patchValue({ company: { siren: result.siren } }). On garde la même discipline — patchValue partiel, emitEvent: false si on ne veut pas re-trigger l'async validator qui vient justement de tourner côté serveur.

Markdown du champ IA et sécurité

Si le champ assisté est rendu en aperçu Markdown (et pas juste un <textarea>), on ne fait jamais [innerHTML] brut sur du contenu LLM : on passe par un parser (marked) puis DomSanitizer.sanitize(SecurityContext.HTML, …). Le contenu généré est une entrée non fiable — un prompt injecté peut tenter d'émettre du <script> ou un onclick. La règle staff : traiter la sortie LLM comme une saisie utilisateur hostile, côté affichage comme côté validation avant envoi à l'API.

Côté NestJS (couvert dans les fichiers backend) : l'endpoint /api/ai/fill est un client LLM injecté via forRootAsync (jamais new Anthropic() dans un field), il streame en SSE, passe l'AbortSignal de la requête HTTP au SDK pour couper la génération à la déconnexion, et applique idempotency + rate-limit + cost-guard à l'edge. Modèles de référence : claude-opus-4-8 (flagship), claude-sonnet-4-6, claude-haiku-4-5.


🔄 Versions — Angular 16 → 20

VersionÉvolutions clés pour Reactive Forms
Angular 14Typed Forms (GA), NonNullableFormBuilder.
Angular 16takeUntilDestroyed() simplifie le nettoyage des souscriptions sur valueChanges. toSignal() permet de matérialiser un form en signal.
Angular 17Standalone par défaut. Le control flow @if / @for remplace *ngIf / *ngFor dans les templates de formulaires — meilleure lisibilité, meilleur typing.
Angular 18events observable du formulaire (form.events) qui unifie value, status, pristine et touched dans un seul flux typé (FormSubmittedEvent, ValueChangeEvent, StatusChangeEvent, TouchedChangeEvent, PristineChangeEvent).
Angular 19 / 20Maturation de l'interop signaux ↔ forms classiques (toSignal), prototypes internes des signal forms.
Angular 21Signal forms livrés en @experimental : form(modelSignal, schemaFn)FieldTree, directive [control], validators fonctionnels (required, email, min…) depuis @angular/forms/signals, plus compatForm/SignalFormControl pour l'interop.
Angular 22Cible de stabilisation (GA) de l'API signal forms.

Les Reactive Forms "classiques" restent pleinement supportés et restent la recommandation par défaut pour 2026. Les signal forms sont @experimental jusqu'à la v22 : à explorer dans un module pilote isolé, jamais dans une refacto massive de production. Et rappel structurant : ce ne sont pas une migration des Reactive Forms mais un modèle distinct (source de vérité = signal du modèle, pas arbre de contrôles).


⚠️ Pitfalls — 6-10

  1. Oublier nonNullable: true sur les contrôles destinés à être envoyés à l'API. Résultat : un type string | null qui pollue tout le code aval et force des coercions partout.
  2. Lire form.value au lieu de form.getRawValue() quand des contrôles sont disabled. value exclut les contrôles désactivés ; getRawValue() les inclut. Choisir explicitement selon le besoin métier.
  3. Boucle infinie sur valueChanges quand un handler modifie un autre contrôle qui réémet. Remède : setValue(x, { emitEvent: false }) ou filtrer en amont avec distinctUntilChanged().
  4. Souscription qui fuit à valueChanges dans ngOnInit sans nettoyage. Solution : takeUntilDestroyed() (Angular 16+) appelé dans un injection context (constructeur ou inject()).
  5. Async validator qui spamme l'API parce que updateOn: 'change' est conservé. Toujours combiner async validator avec updateOn: 'blur' ou avec un debounceTime interne sur valueChanges.
  6. Cross-field validator placé sur un enfant : il faut un validator au niveau du group (paramètre validators du second argument de fb.group(...)). Sinon il n'a pas accès aux contrôles frères.
  7. Affichage d'erreurs immédiat sans vérifier touched. Mauvaise UX : tous les champs sont rouges à l'ouverture.
  8. markAsTouched() oublié avant scrolling vers la première erreur. Sans cela, les erreurs ne s'affichent pas si on a une stratégie "afficher si touched".
  9. FormArray reset incomplet : formArray.reset() ne supprime pas les éléments, il ne fait que reset leur valeur. Pour vider, utiliser formArray.clear().
  10. Type widening sur FormBuilder : fb.group({ a: 1 }) peut produire FormGroup<{ a: FormControl<number | null> }> au lieu du type attendu. Préférer NonNullableFormBuilder ou typer explicitement l'interface.

🧪 Testing

Les reactive forms sont l'un des coins d'Angular les plus testables, précisément parce que la logique vit dans la classe. Pas besoin de TestBed la plupart du temps.

ts
import { FormControl, FormGroup, NonNullableFormBuilder, Validators } from '@angular/forms';

describe('Signup form', () => {
  it('marks the form invalid when email is malformed', () => {
    const form = new FormGroup({
      email: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.email] }),
    });

    form.controls.email.setValue('not-an-email');
    expect(form.valid).toBe(false);
    expect(form.controls.email.errors?.['email']).toBeTruthy();
  });

  it('triggers cross-field validator when passwords mismatch', () => {
    const form = new FormGroup(
      {
        password: new FormControl('hunter2', { nonNullable: true }),
        confirm: new FormControl('hunter3', { nonNullable: true }),
      },
      { validators: [passwordsMatch] },
    );

    expect(form.errors?.['passwordMismatch']).toBe(true);
    form.controls.confirm.setValue('hunter2');
    expect(form.errors).toBeNull();
  });
});

Pour les async validators, on injecte un mock du HttpClient (ou du service métier) et on contrôle le timing avec fakeAsync / tick :

ts
it('flags emailTaken when API returns taken:true', fakeAsync(() => {
  const http = { get: jasmine.createSpy().and.returnValue(of({ taken: true })) };
  const validator = makeEmailTakenValidator(http as any);
  const ctrl = new FormControl('[email protected]', { nonNullable: true, asyncValidators: [validator] });

  ctrl.updateValueAndValidity();
  tick(300); // pour le debounceTime
  expect(ctrl.errors?.['emailTaken']).toBe(true);
}));

Pour les tests UI (composant + template), on utilise ComponentFixture :

ts
it('disables submit while form is invalid', () => {
  const fixture = TestBed.createComponent(SignupComponent);
  fixture.detectChanges();
  const btn = fixture.nativeElement.querySelector('button[type=submit]') as HTMLButtonElement;
  expect(btn.disabled).toBe(true);

  fixture.componentInstance.form.patchValue({
    email: '[email protected]',
    password: 'longenoughpwd',
    confirm: 'longenoughpwd',
  });
  fixture.detectChanges();
  expect(btn.disabled).toBe(false);
});

Règle pratique : 80 % des tests de form sont des tests de classe pure (sans TestBed), 20 % sont des tests d'intégration template. C'est l'inverse pour les template-driven forms — voir le doc dédié.


🎬 Cas d'usage concrets

Scénario 1 — Cabinet juridique, formulaire d'intake client

Un cabinet juridique construit un formulaire d'intake client : 90 champs répartis en 8 sections (identité, coordonnées, type de litige, parties adverses, pièces jointes, conflit d'intérêts, mandat, signature). La validation est complexe : sections conditionnelles (si « société », alors RIB obligatoire ; si « particulier », alors RIB caché), validators cross-field (date de naissance < date du litige), validation async (vérification du SIREN auprès de l'INSEE).

Reactive Forms est le bon choix. L'équipe modélise un FormGroup typé avec sous-groupes (identity, litigation, parties en FormArray), branche les validators dans le code TypeScript (testables sans TestBed), et utilise valueChanges + setValidators pour activer dynamiquement les champs RIB. Le composant template ne contient qu'environ 200 lignes de HTML (binding via formControlName), tandis que la logique de validation tient dans un fichier dédié intake-form.builder.ts.

Le tech lead a imposé deux règles : (1) toute la logique de validation est dans la classe, jamais dans le template ; (2) les tests unitaires construisent le FormGroup sans TestBed et valident chaque scénario en quelques lignes. Résultat : 95 % de couverture sur la logique de validation, et la suite de tests tourne en 800 ms.

Scénario 2 — Banque, KYC multi-step avec validation IBAN

Une banque digitale onboarde ses clients particuliers via un formulaire KYC en 5 étapes : identité, adresse, justificatifs, RIB d'un compte tiers, signature. Chaque étape a son propre FormGroup, et l'étape « ne peut être validée » que si la précédente l'est (cross-step validation).

L'équipe utilise un FormGroup racine contenant les 5 sous-groupes. Le navigateur d'étapes lit form.get('step1')?.valid pour autoriser le passage à l'étape suivante. La validation IBAN est un async validator custom qui appelle l'API interne de la banque (vérification format + existence + lutte anti-fraude). Le validator retourne un Observable<ValidationErrors | null>, branché via asyncValidators sur le contrôle IBAN.

Détails subtils : (a) le validator async ne se déclenche qu'après les validators sync (économie d'appels API), (b) updateOn: 'blur' sur le champ IBAN évite de spammer le backend à chaque caractère tapé, (c) un indicateur de loading est exposé via form.get('iban')?.pending dans le template.

Scénario 3 — SaaS RH, formulaire candidat ATS

Un éditeur d'ATS propose un formulaire de candidature pour les sites carrières de ses clients. Le formulaire est généré dynamiquement à partir d'une config JSON définie par l'employeur (chaque entreprise choisit ses questions). Reactive Forms est idéal car on peut construire le FormGroup programmatiquement à partir de la config.

L'équipe a un service DynamicFormBuilder qui prend la config (Field[]) et retourne un FormGroup. Chaque Field déclare son type, ses validators et ses dépendances conditionnelles (« montrer le champ X si Y === valeur »). Le template itère sur les champs et délègue au composant générique <app-field> qui sait rendre text|email|select|date|file.

Les FormArray sont utilisés pour les sections répétables (expériences professionnelles, formations). Un bouton « + Ajouter » fait experiencesArray.push(buildExperienceGroup()). La validation cross-field (date de fin > date de début dans une expérience) est branchée sur chaque sous-FormGroup créé.


🛠️ Exemple end-to-end

Use case : formulaire KYC d'une banque, 2 étapes simplifiées (identité + RIB), avec FormGroup typé, validators sync et async (vérification IBAN), submit et test classe pure.

ts
// kyc.types.ts
export interface KycValue {
  identity: { firstName: string; lastName: string; birthDate: string };
  bank: { iban: string };
}
ts
// iban.validator.ts
import { AbstractControl, AsyncValidatorFn, ValidationErrors } from '@angular/forms';
import { Observable, map, of, timer, switchMap, catchError } from 'rxjs';

function isFormatValid(iban: string): boolean {
  const cleaned = iban.replace(/\s+/g, '').toUpperCase();
  return /^[A-Z]{2}\d{2}[A-Z0-9]{1,30}$/.test(cleaned);
}

export function ibanAsyncValidator(
  check: (iban: string) => Observable<{ valid: boolean }>,
): AsyncValidatorFn {
  return (control: AbstractControl): Observable<ValidationErrors | null> => {
    const value = (control.value ?? '').trim();
    if (!value) return of(null);
    if (!isFormatValid(value)) return of({ ibanFormat: true });
    return timer(300).pipe(
      switchMap(() => check(value)),
      map((r) => (r.valid ? null : ({ ibanUnknown: true } as ValidationErrors))),
      catchError(() => of({ ibanCheckFailed: true } as ValidationErrors)),
    );
  };
}
ts
// kyc-form.builder.ts
import { Injectable, inject } from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms';
import { ibanAsyncValidator } from './iban.validator';
import { IbanCheckService } from './iban-check.service';

@Injectable({ providedIn: 'root' })
export class KycFormBuilder {
  private readonly fb = inject(FormBuilder).nonNullable;
  private readonly ibanCheck = inject(IbanCheckService);

  build() {
    return this.fb.group({
      identity: this.fb.group({
        firstName: this.fb.control('', [Validators.required, Validators.maxLength(50)]),
        lastName: this.fb.control('', [Validators.required, Validators.maxLength(50)]),
        birthDate: this.fb.control('', [Validators.required]),
      }),
      bank: this.fb.group({
        iban: this.fb.control('', {
          validators: [Validators.required],
          asyncValidators: [ibanAsyncValidator((iban) => this.ibanCheck.check(iban))],
          updateOn: 'blur',
        }),
      }),
    });
  }
}
ts
// kyc.component.ts
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
import { KycFormBuilder } from './kyc-form.builder';
import { KycValue } from './kyc.types';

@Component({
  selector: 'app-kyc',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="form" (ngSubmit)="submit()">
      <fieldset formGroupName="identity">
        <legend>Identité</legend>
        <label>Prénom <input formControlName="firstName" /></label>
        <label>Nom <input formControlName="lastName" /></label>
        <label>Date de naissance <input type="date" formControlName="birthDate" /></label>
      </fieldset>

      <fieldset formGroupName="bank">
        <legend>Coordonnées bancaires</legend>
        <label>IBAN <input formControlName="iban" placeholder="FR76…" /></label>
        @if (form.get('bank.iban')?.pending) { <span>Vérification…</span> }
        @if (form.get('bank.iban')?.errors?.['ibanFormat']) { <p class="error">Format IBAN invalide.</p> }
        @if (form.get('bank.iban')?.errors?.['ibanUnknown']) { <p class="error">IBAN non reconnu.</p> }
      </fieldset>

      <button type="submit" [disabled]="form.invalid || form.pending">Soumettre</button>
    </form>
  `,
})
export class KycComponent {
  private readonly builder = inject(KycFormBuilder);
  protected readonly form = this.builder.build();

  submit(): void {
    if (this.form.invalid) return;
    const value: KycValue = this.form.getRawValue();
    console.log('KYC submit', value);
  }
}
ts
// kyc-form.builder.spec.ts (test classe pure, sans TestBed)
import { KycFormBuilder } from './kyc-form.builder';
import { FormBuilder } from '@angular/forms';
import { of } from 'rxjs';

it('rejects invalid IBAN format synchronously', async () => {
  const fb = new FormBuilder();
  const builder = new (KycFormBuilder as any)();
  (builder as any).fb = fb.nonNullable;
  (builder as any).ibanCheck = { check: () => of({ valid: true }) };

  const form = builder.build();
  form.get('bank.iban')!.setValue('NOT-AN-IBAN');
  expect(form.get('bank.iban')!.errors).toEqual({ ibanFormat: true });
});

Le formulaire est entièrement piloté par la classe : validators sync, validator async débouncé (updateOn: 'blur'), tests sans TestBed. Le template ne contient que du binding — pas de logique de validation.


🧩 Bonus senior — ControlValueAccessor

Le chaînon manquant que beaucoup de devs « 7 ans » n'ont jamais implémenté à la main : comment un composant custom devient utilisable avec formControlName. C'est le contrat ControlValueAccessor (CVA). Un <app-rating>, un toggle stylé, un date-picker maison — tous doivent l'implémenter pour s'intégrer au form comme un <input> natif.

ts
import { Component, forwardRef, signal } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';

@Component({
  selector: 'app-rating',
  standalone: true,
  providers: [
    { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => RatingComponent), multi: true },
  ],
  template: `
    @for (star of stars; track star) {
      <button type="button"
        [disabled]="disabled()"
        [class.filled]="star <= value()"
        (click)="pick(star)" (blur)="onTouched()">★</button>
    }
  `,
})
export class RatingComponent implements ControlValueAccessor {
  protected readonly stars = [1, 2, 3, 4, 5];
  protected readonly value = signal(0);
  protected readonly disabled = signal(false);

  private onChange: (v: number) => void = () => {};
  protected onTouched: () => void = () => {};

  // Angular -> composant
  writeValue(v: number): void { this.value.set(v ?? 0); }
  setDisabledState(d: boolean): void { this.disabled.set(d); }

  // composant -> Angular
  registerOnChange(fn: (v: number) => void): void { this.onChange = fn; }
  registerOnTouched(fn: () => void): void { this.onTouched = fn; }

  protected pick(star: number): void {
    if (this.disabled()) return;
    this.value.set(star);
    this.onChange(star);   // propage la valeur au FormControl
  }
}

Les quatre méthodes mappent exactement le flux bidirectionnel : writeValue + setDisabledState = Angular qui pilote le composant ; registerOnChange + registerOnTouched = le composant qui notifie Angular. Le piège classique : oublier d'appeler onTouched() au blur, ce qui casse toute la stratégie « afficher les erreurs après touched ». Un CVA peut aussi porter ses propres validators en s'enregistrant sur NG_VALIDATORS — pattern utile pour un composant qui sait valider sa propre valeur (un IBAN-input qui valide le format en interne).


🏋️ Exercices

1. FormArray typé + cross-field par ligne

Objectif : construire un éditeur de lignes de facture (description, qty, unitPrice) en FormArray<FormGroup<…>> non nullable, avec un total HT dérivé en signal et un validator par ligne (qty >= 1).

Indice/Solution : interface LineForm explicite, NonNullableFormBuilder.array<FormGroup<LineForm>>([]), getter typé, validator de group sur chaque ligne (pas sur le FormArray). Total = toSignal(this.lines.valueChanges, { initialValue: this.lines.getRawValue() }) puis computed(() => v().reduce(...)). Track sur un id métier, pas $index, pour permettre la suppression au milieu sans re-render fantôme.

2. Composant custom via ControlValueAccessor

Objectif : rendre le <app-rating> ci-dessus pleinement intégré — formControlName="score", requis, désactivable via control.disable(), erreurs affichées après touched.

Indice/Solution : implémenter les 4 méthodes CVA, câbler onTouched() au (blur) du dernier bouton, propager setDisabledState. Ajouter Validators.min(1) sur le FormControl côté parent et vérifier que control.disable() grise bien les étoiles (via setDisabledState). Test classe pure : writeValue(3) puis lire value(), pick(4) puis vérifier que le onChange enregistré a reçu 4.

3. Autosave robuste — production-grade

Objectif : transformer l'autosave naïf (debounceTime + switchMap) en version production : optimistic UI, gestion d'erreur avec retry borné, indicateur « saved/saving/error », et anti-perte sur navigation.

Indice/Solution : valueChangesdebounceTime(800)filter(valid)distinctUntilChanged (comparer un hash, pas JSON.stringify à chaque émission sur gros form) → switchMap(v => api.save(v).pipe(retry({ count: 2, delay: 500 }), catchError(...))). Exposer un signal d'état 'idle'|'saving'|'saved'|'error'. Brancher un CanDeactivate guard qui bloque la navigation si saving en cours ou si une erreur de save est pendante. Piège : switchMap annule la requête mais pas le travail serveur déjà parti — idempotency key côté API.

4. Casser puis réparer — la boucle infinie

Objectif : écrire volontairement une boucle infinie (zip.valueChanges qui setValue sur city, qui setValue sur zip), observer le freeze, puis la corriger de trois façons distinctes.

Indice/Solution : reproduire le gel (ou le ExpressionChangedAfterItHasBeenCheckedError selon le timing). Corrections : (a) setValue(x, { emitEvent: false }) sur le contrôle dérivé ; (b) distinctUntilChanged() pour stopper la propagation quand la valeur n'a pas réellement changé ; (c) repenser le flux en unidirectionnel (un seul contrôle « source », l'autre dérivé en lecture seule via computed). Expliquer pourquoi (a) est un patch, (c) une vraie architecture.

5. Streaming IA dans un FormControl — production-grade

Objectif : reprendre AiSummaryFieldComponent, le rendre incassable : Stop qui annule serveur ET client, reprise après erreur réseau, et aucune perte de la saisie manuelle déjà présente.

Indice/Solution : AbortController par requête, recréé à chaque suggest(). Préserver this.summary.value initial dans buffer avant de streamer (on complète, on n'écrase pas). Sur AbortError, flush() le partiel et passer en 'aborted' (pas 'error'). rAF-coalescing obligatoire. Bonus : timeout client (AbortSignal.timeout(30_000) combiné via AbortSignal.any([...])) et vérification que le serveur reçoit bien la déconnexion (logguer req.on('close') côté NestJS). Piège : si on oublie emitEvent: false, un async validator sur le champ part à chaque token → tempête de requêtes.

6. Form events comme couche d'observabilité

Objectif : instrumenter un form via form.events (Angular 18+) pour mesurer time-to-first-error, taux d'abandon par champ, et durée de remplissage — sans toucher au reste du composant.

Indice/Solution : un seul subscribe sur form.events, instanceof pour raffiner (TouchedChangeEvent, ValueChangeEvent, FormSubmittedEvent). Timestamper le premier ValueChangeEvent et le premier StatusChangeEvent invalide. Détecter l'abandon : dernier champ touched sans FormSubmittedEvent à l'unmount. Tout passe dans un service FormTelemetryService injecté, le composant ne change pas. Piège : ne pas oublier takeUntilDestroyed() dans un injection context.


🎤 En entretien

Q : Pourquoi NonNullableFormBuilder est-il devenu le défaut, et que change-t-il à reset() ? Parce que new FormControl('') infère string | null (le null venant de reset()), ce qui pollue tout le code aval avec des coercions. nonNullable: true (appliqué globalement par NonNullableFormBuilder) fait deux choses : getRawValue() est typé sans null, et reset() revient à la valeur initiale de construction au lieu de null. Le compromis : pour un champ réellement optionnel, on repasse explicitement par FormBuilder pour modéliser le T | null.

Q : Un cross-field validator (mots de passe identiques) — où le place-t-on et pourquoi ? Sur le FormGroup parent (2e argument de fb.group(...)), jamais sur un contrôle enfant. Un validator de contrôle ne reçoit que ce contrôle ; il n'a pas accès aux frères. Le validator de group reçoit l'AbstractControl parent et peut lire group.get('password') et group.get('confirm'). Conséquence template : l'erreur vit sur form.errors, pas sur form.controls.confirm.errors.

Q : Comment éviter qu'un async validator ne spamme l'API, et où branche-t-on l'annulation ? Trois leviers : updateOn: 'blur' (ne valide qu'à la perte de focus), un debounceTime interne dans le validator, et le fait qu'Angular ne lance les async validators qu'après que les sync passent (donc valider le format en sync d'abord économise des appels). L'annulation est implicite : Angular ne garde que le résultat de la dernière exécution, mais pour vraiment couper la requête réseau on construit le validator avec un switchMap (qui unsubscribe la précédente).

Q : form.value vs getRawValue() — quand l'un trahit-il l'autre, et quel impact sécurité ? Un contrôle disabled est exclu de value mais inclus dans getRawValue(). Bug fréquent : on envoie form.value à l'API et un champ calculé/grisé (TVA dérivée, id technique) disparaît silencieusement du payload. Côté sécurité, l'inverse est le vrai piège : ne jamais faire confiance à getRawValue() comme source d'autorité — un champ disabled reste modifiable via la console ; toute règle métier (prix, rôle, montant) doit être revalidée côté serveur. Le form est de l'UX, pas un contrôle de sécurité.

Q : Les signal forms (Angular 21) sont-ils une migration des Reactive Forms ? Quel mental model change ? Non — c'est une réécriture from scratch, pas une évolution. Le renversement clé : avec les Reactive Forms la source de vérité est l'arbre de contrôles (FormGroup) et la valeur en est dérivée ; avec les signal forms la source de vérité est un WritableSignal du modèle métier et le FieldTree (issu de form(model, schema)) n'est qu'une vue qui l'enrobe avec validation/état/soumission. Conséquences concrètes : on bind via [control] (plus de formControlName/ControlContainer), tout l'état est signal (f().valid(), f.email().errors()) donc plus de valueChanges/souscriptions à nettoyer, et la validation devient un schéma fonctionnel par chemin (required(path.email)) plutôt que des Validators attachés à chaque contrôle. Réponse staff sur l'adoption : @experimental jusqu'à la v22, donc pilote isolé uniquement, avec compatForm/SignalFormControl pour l'interop pendant une transition.


🔁 Quand utiliser / éviter

Utiliser les Reactive Forms quand :

  • Le formulaire dépasse 3-4 champs ou contient des sections dynamiques (FormArray).
  • On a besoin de validators cross-field, async, ou conditionnels.
  • On veut tester la logique du formulaire sans toucher au DOM.
  • Le payload de sortie doit être strictement typé pour l'API.
  • On orchestre des side-effects réactifs (auto-save, calcul dérivé, dépendances entre champs).

Préférer Template-driven forms quand :

  • Le formulaire est minuscule (login à 2 champs, recherche inline) et n'a aucune logique transverse.
  • L'équipe est plus à l'aise avec [(ngModel)] qu'avec FormGroup.
  • On prototype rapidement et la cible finale est un autre formulaire.

Éviter les Reactive Forms pour :

  • Un simple input lié à un signal sans validation — un [ngModel] ou un binding direct suffit.
  • Des "formulaires" qui ne sont en fait qu'un single field de filtre — un signal + un effect est plus léger.

Dans 90 % des cas en production, la réponse est : Reactive Forms. Le coût initial (déclarer une interface, instancier un FormBuilder) est négligeable face aux gains de typage, testabilité et orchestration.


🔗 Liens

  • Documentation officielle : Reactive Forms — angular.dev/guide/forms/reactive-forms
  • Typed Forms migration guide — angular.dev/guide/forms/typed-forms
  • FormBuilder et NonNullableFormBuilder API — angular.dev/api/forms/NonNullableFormBuilder
  • Cross-field validation — angular.dev/guide/forms/form-validation#cross-field-validation
  • takeUntilDestroyedangular.dev/api/core/rxjs-interop/takeUntilDestroyed
  • Signal forms (preview) — RFC en cours sur le repo angular/angular

Récap final

Les Reactive Forms reposent sur un arbre typé (FormGroup/FormArray/FormControl) déclaré côté classe. On utilise systématiquement NonNullableFormBuilder et une interface explicite pour obtenir un getRawValue() typé sans null. Les validators sont composables (sync + async), le cross-field se place sur le group parent, et updateOn: 'blur' est indispensable dès qu'un async validator entre en jeu. L'affichage des erreurs se factorise dans un composant à content projection, conditionné sur touched. Les souscriptions valueChanges se nettoient avec takeUntilDestroyed(). Le testing se fait à 80 % en classe pure, sans TestBed. La trajectoire 2026 va vers les signal forms (@experimental en v21, form(modelSignal, schemaFn)FieldTree, directive [control]) — un modèle distinct où la source de vérité est un signal du modèle, pas un arbre de contrôles —, mais en production on reste sur l'API classique, éventuellement interopée avec toSignal().

Bibliothèque tech perso — Achref