Skip to content

Template-driven forms — mental model, comparaison, migration

TL;DR — Les template-driven forms d'Angular construisent l'arbre de contrôles depuis le template. Une directive ngModel sur un <input> crée silencieusement un FormControl en arrière-plan, et ngForm agrège ces contrôles dans un FormGroup racine accessible via #f="ngForm". C'est l'approche idéale pour des formulaires simples et stables (login, recherche, contact), où la logique tient dans le HTML. Dès que l'on a besoin de typage strict, de validators cross-field, d'async validators non triviaux, de tests purs ou de manipulation dynamique d'FormArray, on bascule sur Reactive Forms. La migration template → reactive est mécanique mais doit être faite avant d'ajouter de la complexité, pas après.


🧠 Mental model — ASCII + analogie

Côté template-driven, le template est la source de vérité structurelle. Le composant ne déclare rien : c'est ngModel qui crée les contrôles à la volée pendant le rendu.

   ┌──────────────────────────────────────────────────────────────┐
   │                        Template HTML                         │
   │                                                              │
   │   <form #f="ngForm">                                         │
   │     <input ngModel name="email"    [...]>  ─► FormControl    │
   │     <input ngModel name="password" [...]>  ─► FormControl    │
   │     <div ngModelGroup="address">                             │
   │        <input ngModel name="city"  [...]>  ─► FormControl    │
   │     </div>                                                   │
   │   </form>                                                    │
   │                                                              │
   │            ┌───────────────────────────────────┐             │
   │            │       FormGroup auto-créé         │             │
   │            │  (accessible via f.value, f.form) │             │
   │            └───────────────────────────────────┘             │
   └──────────────────────────────────────────────────────────────┘

Analogie : c'est la différence entre dessiner un plan d'architecte avant de construire (reactive) et assembler une étagère IKEA dont la structure émerge des pièces (template-driven). Pour une étagère, la méthode IKEA est plus rapide. Pour une maison, on veut un plan.

L'autre angle utile : les template-driven forms sont synchrones à la vue. Le FormControl n'existe qu'une fois la directive ngModel instanciée par le rendu — ce qui crée des subtilités de timing dans les hooks de cycle de vie (cf. pitfalls).


🛠️ Code minimal (ts + html)

Un formulaire de contact template-driven, complet, avec validators, group imbriqué et soumission.

ts
import { Component, inject } from '@angular/core';
import { FormsModule, NgForm } from '@angular/forms';
import { HttpClient } from '@angular/common/http';

interface ContactModel {
  fullName: string;
  email: string;
  message: string;
  address: {
    city: string;
    zip: string;
  };
  agree: boolean;
}

@Component({
  selector: 'app-contact',
  standalone: true,
  imports: [FormsModule],
  templateUrl: './contact.component.html',
})
export class ContactComponent {
  private http = inject(HttpClient);

  model: ContactModel = {
    fullName: '',
    email: '',
    message: '',
    address: { city: '', zip: '' },
    agree: false,
  };

  submit(form: NgForm): void {
    if (form.invalid) {
      // marque tous les contrôles comme touched pour révéler les erreurs
      Object.values(form.controls).forEach((c) => c.markAsTouched());
      return;
    }
    this.http.post('/api/contact', this.model).subscribe(() => form.resetForm());
  }
}
html
<form #f="ngForm" (ngSubmit)="submit(f)" novalidate>
  <label>
    Nom complet
    <input
      name="fullName"
      [(ngModel)]="model.fullName"
      required
      minlength="2"
      #fullName="ngModel"
    />
    @if (fullName.touched && fullName.invalid) {
      <small class="error">
        @if (fullName.errors?.['required']) { Le nom est requis. }
        @if (fullName.errors?.['minlength']) { Minimum 2 caractères. }
      </small>
    }
  </label>

  <label>
    Email
    <input
      type="email"
      name="email"
      [(ngModel)]="model.email"
      required
      email
      #email="ngModel"
    />
    @if (email.touched && email.invalid) {
      <small class="error">
        @if (email.errors?.['required']) { Email requis. }
        @if (email.errors?.['email']) { Format invalide. }
      </small>
    }
  </label>

  <div ngModelGroup="address" #address="ngModelGroup">
    <h3>Adresse</h3>
    <input name="city" [(ngModel)]="model.address.city" required placeholder="Ville" />
    <input
      name="zip"
      [(ngModel)]="model.address.zip"
      required
      pattern="\d{5}"
      placeholder="Code postal"
    />
  </div>

  <label>
    Message
    <textarea
      name="message"
      [(ngModel)]="model.message"
      required
      minlength="20"
      rows="6"
    ></textarea>
  </label>

  <label>
    <input type="checkbox" name="agree" [(ngModel)]="model.agree" required />
    J'accepte les conditions
  </label>

  <button type="submit" [disabled]="f.invalid">Envoyer</button>
</form>

Quelques points à noter :

  • [(ngModel)] est le sucre [ngModel]="x" (ngModelChange)="x = $event" — un banana in a box.
  • L'attribut name="..." est obligatoire dès qu'un contrôle est dans un <form> ; sans nom, Angular lève une erreur runtime.
  • #fullName="ngModel" expose la directive sur la variable locale — c'est elle qui porte touched, errors, valid, etc.
  • #f="ngForm" expose le NgForm (qui contient un FormGroup).

🎯 Patterns courants

[(ngModel)] vs [ngModel] + (ngModelChange)

Le banana-in-the-box [(ngModel)]="model.field" est l'équivalent exact de [ngModel]="model.field" (ngModelChange)="model.field = $event". Dans 95 % des cas, on utilise le banana. On éclate les deux directions uniquement quand on veut intercepter la valeur avant de l'assigner — par exemple pour la normaliser :

html
<input
  [ngModel]="model.phone"
  (ngModelChange)="model.phone = normalize($event)"
  name="phone"
/>

Important : [ngModel] sans (ngModelChange) ne crée pas un binding one-way au sens où le champ deviendrait read-only. Il crée un binding initial, mais l'utilisateur peut quand même taper — c'est juste que model.phone ne sera pas mis à jour. Source classique de bugs "mon modèle ne se met pas à jour".

ngForm et NgForm

La directive ngForm (auto-appliquée à tout <form> quand FormsModule est importé) crée un NgForm qui agrège tous les NgModel enfants. On y accède via une variable locale :

html
<form #f="ngForm" (ngSubmit)="submit(f)">

f.value est l'objet aplati { fullName: '...', email: '...', address: { city: '...' } }. f.invalid, f.dirty, f.touched agrègent les états des enfants. f.resetForm() remet à zéro toutes les valeurs et les flags.

ngModelGroup

Pour créer un sous-objet dans la valeur de form, on entoure les contrôles avec ngModelGroup="address". La valeur agrégée devient :

ts
{
  fullName: 'Alice',
  email: '[email protected]',
  address: { city: 'Paris', zip: '75001' },
}

ngModelGroup est l'équivalent template d'un FormGroup imbriqué. Il accepte aussi des validators (#group="ngModelGroup", puis lecture de group.errors).

Validators dans le template

Les validators built-in s'activent par attribut HTML : required, minlength, maxlength, pattern, email, min, max. Aucun import additionnel. On reconnaît ces attributs : ils sont identiques aux validators HTML natifs, mais Angular les capture et les transforme en Validators.required, etc.

Pour un validator custom, on écrit une directive :

ts
import { Directive } from '@angular/core';
import { AbstractControl, NG_VALIDATORS, ValidationErrors, Validator } from '@angular/forms';

@Directive({
  selector: '[appForbiddenName]',
  standalone: true,
  providers: [{ provide: NG_VALIDATORS, useExisting: ForbiddenNameDirective, multi: true }],
})
export class ForbiddenNameDirective implements Validator {
  validate(control: AbstractControl): ValidationErrors | null {
    return control.value === 'admin' ? { forbiddenName: true } : null;
  }
}

Puis dans le template :

html
<input name="username" [(ngModel)]="model.username" appForbiddenName required />

C'est le pattern officiel, mais il a un coût : chaque validator custom requiert sa propre directive, son provider NG_VALIDATORS, et son import dans le composant standalone. Pour deux ou trois validators custom, ça reste raisonnable. Pour davantage, c'est un signal de bascule vers Reactive Forms.

Async validators

Même schéma avec NG_ASYNC_VALIDATORS et AsyncValidator (au lieu de Validator). Le validate() retourne un Observable<ValidationErrors | null> ou une Promise. Le problème : on n'a aucun contrôle direct sur updateOn au niveau de l'attribut HTML — on doit passer par [ngModelOptions]="{ updateOn: 'blur' }" sur chaque contrôle concerné.

html
<input
  name="email"
  [(ngModel)]="model.email"
  appEmailAvailable
  [ngModelOptions]="{ updateOn: 'blur' }"
/>

C'est faisable mais verbeux. C'est la raison numéro un pour laquelle les async validators non triviaux poussent vers Reactive Forms.

Composants custom dans un form template-driven — ControlValueAccessor

Tôt ou tard, un champ n'est plus un <input> natif mais un composant maison (rating en étoiles, color picker, toggle stylé). Pour qu'il participe à [(ngModel)] exactement comme un input natif, il doit implémenter ControlValueAccessor (CVA). C'est le pont entre le modèle Angular et le DOM — ngModel ne sait parler qu'à un CVA, jamais directement à un <input> (les directives built-in DefaultValueAccessor, CheckboxControlValueAccessor, etc. en sont déjà). Beaucoup de seniors ne réalisent qu'à l'entretien que [(ngModel)] est une abstraction CVA.

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

@Component({
  selector: 'app-star-rating',
  standalone: true,
  providers: [
    // self-registration : ce composant DEVIENT le value accessor du ngModel parent
    { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => StarRatingComponent), multi: true },
  ],
  template: `
    @for (star of stars; track star) {
      <button
        type="button"
        [attr.aria-pressed]="star <= value()"
        [disabled]="disabled()"
        (click)="select(star)"
        (blur)="onTouched()"
      >{{ star <= value() ? '★' : '☆' }}</button>
    }
  `,
})
export class StarRatingComponent implements ControlValueAccessor {
  protected readonly stars = [1, 2, 3, 4, 5];
  protected readonly value = signal(0);
  protected readonly disabled = signal(false);

  // callbacks fournis par Angular au moment du bind
  private onChange: (v: number) => void = () => {};
  protected onTouched: () => void = () => {};

  writeValue(v: number): void { this.value.set(v ?? 0); }      // modèle → vue
  registerOnChange(fn: (v: number) => void): void { this.onChange = fn; }
  registerOnTouched(fn: () => void): void { this.onTouched = fn; }
  setDisabledState(isDisabled: boolean): void { this.disabled.set(isDisabled); } // [disabled]/control.disable()

  select(star: number): void {
    if (this.disabled()) return;
    this.value.set(star);
    this.onChange(star);                                       // vue → modèle (propage à ngModel)
  }
}
html
<!-- s'utilise comme n'importe quel ngModel : aucun code spécifique côté form -->
<app-star-rating name="rating" [(ngModel)]="model.rating" required></app-star-rating>

Les quatre méthodes forment un contrat strict — l'erreur classique est d'oublier registerOnTouched (les erreurs ne s'affichent jamais car le contrôle ne passe pas touched) ou setDisabledState (le composant ignore control.disable() côté reactive). Staff reasoning : un CVA bien écrit est agnostique de l'API de forms — le même app-star-rating marche en template-driven ET en reactive sans modification. C'est le seul morceau de code de formulaire qui survit intact à une migration.

Standalone controls hors <form>

Quand un [(ngModel)] n'est pas à l'intérieur d'un <form>, il n'a pas besoin de name. Quand il est dans un <form>, ou bien il faut name="...", ou bien on indique explicitement qu'il est autonome :

html
<input [(ngModel)]="searchTerm" [ngModelOptions]="{ standalone: true }" />

Cas typique : un input de recherche placé dans un <form> mais qui ne fait pas partie du payload soumis (par exemple un filtre local sur la liste affichée).

Lecture programmatique des contrôles enfants

f.controls est un dictionnaire indexé par nom. On y accède directement :

ts
submit(form: NgForm): void {
  const emailCtrl = form.controls['email'];
  if (emailCtrl?.pending) {
    // async validator encore en cours
    return;
  }
}

À l'inverse de Reactive Forms, ce dictionnaire n'est pas typé : form.controls['email'] est AbstractControl, pas FormControl<string>. C'est l'une des raisons fondamentales pour lesquelles Reactive Forms gagne dès que les contrôles deviennent nombreux.

Soumission et reset

ts
submit(form: NgForm): void {
  if (form.invalid) return;
  this.api.send(form.value).subscribe(() => form.resetForm(this.initialModel));
}

resetForm() sans argument remet toutes les valeurs à null (ou '' selon le type). Avec un argument, il remet aux valeurs fournies. resetForm() reset aussi les flags touched / dirty / submitted — ce que (ngSubmit)="model = {...}" ne fait pas. Toujours préférer resetForm().

Accès dynamique à la valeur typée

Le défaut majeur : f.value est de type any. On peut le caster avec une assertion, mais c'est une promesse non vérifiée :

ts
submit(form: NgForm): void {
  const value = form.value as ContactModel; // pas de vérification
}

En pratique, on utilise directement this.model (qui est typé) plutôt que form.value lors de la soumission. Le NgForm sert alors uniquement pour form.invalid et form.resetForm().

ngModelChange vs banana

ngModelChange émet une valeur typée selon le contrôle (par exemple boolean pour une checkbox). On l'utilise pour intercepter sans assigner :

html
<input type="checkbox" [ngModel]="model.agree" (ngModelChange)="onAgreeChange($event)" name="agree" />

Côté composant :

ts
onAgreeChange(value: boolean): void {
  this.model.agree = value;
  if (value) this.tracker.event('agreed_to_terms');
}

C'est plus propre que d'écrire (change) ou (input) sur l'élément natif, parce que ngModelChange émet après qu'Angular ait traité la valeur (coercition de type, par exemple 'on'true pour les checkboxes).

Observer valueChanges au niveau du form

NgForm expose un Observable valueChanges qui agrège les mutations de tous les contrôles enfants. C'est utile pour un autosave, mais on doit attendre que f soit disponible (post-rendu) :

ts
@ViewChild('f') form?: NgForm;

ngAfterViewInit(): void {
  this.form?.valueChanges
    ?.pipe(debounceTime(500), takeUntilDestroyed(this.destroyRef))
    .subscribe((value) => this.autosave(value));
}

Le ?. est obligatoire — au premier ngAfterViewInit, form.valueChanges peut encore être undefined selon le timing. Avec Reactive Forms, le FormGroup existe dès la construction, ce qui supprime cette friction.


🔄 Versions — Angular 16 → 20

VersionÉvolutions pour Template-driven forms
Angular 16takeUntilDestroyed() utilisable pour souscrire à f.valueChanges proprement.
Angular 17Standalone par défaut → imports: [FormsModule] explicite dans le composant. Control flow @if / @for dans les templates de form.
Angular 18NgForm.events aligne le flux d'événements sur celui des Reactive Forms (FormSubmittedEvent, etc.).
Angular 19Pas de changement majeur côté template-driven ; l'effort va sur signal forms (qui ne concernent pas l'API template-driven).
Angular 20API template-driven stable et inchangée. Pas de plan de dépréciation.

Les template-driven forms ne sont pas dépréciés et ne le seront pas — ils restent la voie low-friction pour les cas simples.

Le troisième larron : Signal Forms (Angular 20, experimental)

Un point que beaucoup de seniors ratent en 2026 : Angular ne se résume plus à « template-driven vs reactive ». Depuis Angular 19/20, une troisième API émerge — Signal Forms (@angular/forms/signals, experimental) — qui fait du signal la source de vérité et dérive la validité réactivement. Mentalement :

AxeTemplate-drivenReactiveSignal Forms (exp.)
Source de véritéLe template (ngModel)La classe (FormGroup)Un signal() de modèle
Création des contrôlesÀ la volée, au renduÀ la constructionDérivée du schéma signal
Typageany sur f.valueFort (FormGroup<T>)Fort, inféré du signal
Change detectionZone.js friendlyZone.js friendlyZoneless-natif
Maturité (mi-2026)Stable, geléStableExperimental, API mouvante

Pour un dev qui démarre aujourd'hui un nouveau formulaire simple, template-driven reste le bon choix par défaut (zéro dépendance experimental). Mais sache positionner les trois : en entretien, citer Signal Forms comme « la direction où Angular pousse la réactivité fine, signal-first et zoneless » te distingue. Ne l'utilise pas encore en prod critique — l'API n'est pas stabilisée.

Staff reasoning : choisir une API de forms, c'est choisir où vit la source de vérité. Template = vue, Reactive = classe, Signal = signal. Le reste (validators, async, reset) découle de ce choix structurel.


⚠️ Pitfalls — 6-10

  1. Oublier name="..." sur un input avec [(ngModel)] dans un <form> : erreur runtime "If ngModel is used within a form tag, either the name attribute must be set or the form control must be defined as 'standalone' in ngModelOptions". Pour un contrôle isolé hors form, on ajoute [ngModelOptions]="{ standalone: true }".
  2. f.value typé any : on perd la sécurité de type. Workaround : utiliser directement le model typé, et n'invoquer f que pour les flags.
  3. Mutation directe vs resetForm() : model = { ...initial } ne reset pas touched/dirty/submitted. Toujours form.resetForm(initialModel).
  4. Timing de ngOnInit : le NgForm et ses NgModel ne sont pas encore initialisés dans ngOnInit — ils le sont après le premier rendu. Pour lire la form après init, utiliser ngAfterViewInit ou @ViewChild.
  5. Validators custom verbeux : chaque validator demande une directive + provider NG_VALIDATORS + multi: true. Une fonction ValidatorFn ne se branche pas directement sur un [(ngModel)]. Si on a 5+ validators custom, on bascule sur Reactive Forms.
  6. Async validators sans updateOn: 'blur' : l'API est spammée à chaque frappe. Toujours [ngModelOptions]="{ updateOn: 'blur' }" quand un async validator est attaché.
  7. ngModelGroup sans <form> englobant ne fonctionne pas. Le group doit toujours vivre à l'intérieur d'un <form ngForm>.
  8. Two-way avec un signal : [(ngModel)]="mySignal" ne fonctionne pas comme attendu — ngModel lit la valeur du signal mais l'assignment écrase la référence. Préférer [ngModel]="mySignal()" (ngModelChange)="mySignal.set($event)", ou utiliser un binding [(value)] natif sur les inputs (Angular 19+).
  9. Tests fragiles : tester un template-driven form requiert fakeAsync + tick() pour laisser le binding s'initialiser. Plus de TestBed boilerplate qu'avec reactive.
  10. Désactivation conditionnelle via [disabled] sur un contrôle ngModel : Angular affiche un warning ("It looks like you're using the disabled attribute with a reactive form directive"). Pour template-driven, c'est OK ; pour reactive, il faut control.disable() programmatique.

🧪 Testing

Tester un template-driven form requiert plus de cérémonie : le NgForm n'existe qu'après le premier detectChanges(), et les flags touched / dirty sont déclenchés par des événements DOM réels.

ts
import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
import { FormsModule, NgForm } from '@angular/forms';
import { By } from '@angular/platform-browser';
import { ContactComponent } from './contact.component';

describe('ContactComponent', () => {
  let fixture: ComponentFixture<ContactComponent>;
  let component: ContactComponent;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [FormsModule, ContactComponent],
    });
    fixture = TestBed.createComponent(ContactComponent);
    component = fixture.componentInstance;
  });

  it('marks the form invalid initially', fakeAsync(() => {
    fixture.detectChanges();
    tick();
    const form = fixture.debugElement.query(By.directive(NgForm)).injector.get(NgForm);
    expect(form.invalid).toBe(true);
  }));

  it('becomes valid when all fields are filled', fakeAsync(() => {
    fixture.detectChanges();
    tick();

    component.model = {
      fullName: 'Alice Martin',
      email: '[email protected]',
      message: 'This is a sufficiently long message body.',
      address: { city: 'Paris', zip: '75001' },
      agree: true,
    };

    fixture.detectChanges();
    tick();

    const form = fixture.debugElement.query(By.directive(NgForm)).injector.get(NgForm);
    expect(form.valid).toBe(true);
  }));

  it('shows error message when email is touched and invalid', fakeAsync(() => {
    fixture.detectChanges();
    tick();

    const emailInput: HTMLInputElement = fixture.nativeElement.querySelector('input[name=email]');
    emailInput.value = 'not-an-email';
    emailInput.dispatchEvent(new Event('input'));
    emailInput.dispatchEvent(new Event('blur'));
    fixture.detectChanges();
    tick();

    const errorEl: HTMLElement = fixture.nativeElement.querySelector('small.error');
    expect(errorEl?.textContent).toContain('Format invalide');
  }));
});

Comparaison avec Reactive Forms : ici on a besoin de TestBed, fakeAsync, ticks, et de dispatcher des événements DOM réels. Avec Reactive Forms, on aurait écrit deux lignes en classe pure. C'est l'argument testing en faveur des Reactive Forms.


🏭 Préoccupations production — a11y, perf, observabilité, sécurité

Un staff engineer ne juge pas un form sur « est-ce que ça marche en dev » mais sur ces quatre axes.

Accessibilité (souvent l'angle mort le plus coûteux)

Un message d'erreur qui change de texte via @if n'est pas annoncé par un lecteur d'écran s'il n'est pas câblé correctement. Le minimum sérieux :

html
<input
  name="email"
  [(ngModel)]="model.email"
  required email
  #emailRef="ngModel"
  [attr.aria-invalid]="emailRef.invalid && emailRef.touched"
  aria-describedby="email-err"
/>
@if (emailRef.invalid && emailRef.touched) {
  <small id="email-err" class="error" role="alert" aria-live="assertive">Email invalide.</small>
}
  • aria-invalid reflète l'état du contrôle pour la techno d'assistance.
  • aria-describedby + id lie l'erreur au champ (le lecteur lit l'erreur en focusant le champ).
  • role="alert" / aria-live fait annoncer le changement de texte dynamiquement.
  • Au submit invalide, en plus de markAsTouched(), déplace le focus sur le premier champ en erreur (firstInvalid?.focus()) — sinon l'utilisateur clavier reste bloqué sur le bouton.

Performance — le coût caché de Zone.js sur les gros forms

Chaque frappe dans un [(ngModel)] déclenche un cycle de change detection sur toute l'application (sauf OnPush bien posé). Sur un form de 40+ champs, ou un form qui coexiste avec une liste lourde, ça devient le hotspot du profiler. Trois leviers :

LevierEffetQuand
[ngModelOptions]="{ updateOn: 'blur' }"CD seulement à la perte de focus, pas par frappeChamps nombreux, validators async
ChangeDetectionStrategy.OnPushRestreint la CD aux composants dont une input/signal a changéToujours, par défaut
Migration zoneless (Angular 18+)Supprime le patch global Zone.js ; la CD est pilotée par signalsNouveau projet ; template-driven y reste viable mais signal-based devient plus naturel

Mesure avant d'optimiser : ng.profiler.timeChangeDetection() ou l'onglet Performance des DevTools avec la couche Angular. L'intuition « template-driven est lent » est fausse à petite échelle ; elle devient vraie sans updateOn: 'blur' au-delà de quelques dizaines de contrôles.

Observabilité — instrumenter un funnel de formulaire

Un form de production se mesure : taux d'abandon, champ qui fait abandonner, temps de remplissage, taux d'erreur de validation par champ. On greffe ça sans polluer le form :

ts
ngAfterViewInit(): void {
  this.form?.valueChanges
    ?.pipe(debounceTime(2000), takeUntilDestroyed(this.destroyRef))
    .subscribe(() => this.analytics.track('form_field_interaction', { form: 'contact' }));
}

submit(form: NgForm): void {
  if (form.invalid) {
    const errored = Object.entries(form.controls)
      .filter(([, c]) => c.invalid)
      .map(([name]) => name);
    this.analytics.track('form_submit_blocked', { fields: errored }); // quels champs bloquent
    Object.values(form.controls).forEach((c) => c.markAsTouched());
    return;
  }
  this.analytics.track('form_submit_ok');
  // ...
}

Sécurité — la validation client est de l'UX, jamais une garantie

required, pattern, email côté template sont cosmétiques du point de vue sécurité : trivialement contournables (DevTools, curl, désactiver JS). Règles staff :

  • Toute validation doit être dupliquée et autoritaire côté serveur (DTO + class-validator côté NestJS). Le client valide pour l'UX, le serveur valide pour la vérité.
  • Ne jamais faire confiance à f.value pour des décisions de sécurité (autorisation, prix, rôles) — ces champs ne devraient pas être dans le form, ou doivent être re-vérifiés serveur.
  • Attention au XSS sur les champs réinjectés : Angular échappe par défaut, mais tout [innerHTML] d'un contenu de form doit passer par DomSanitizer.
  • Rate-limit et CSRF se gèrent à l'edge serveur, pas dans le form.

🎬 Cas d'usage concrets

Scénario 1 — E-commerce, formulaire de contact simple

Une boutique en ligne de cosmétiques a une page « Contactez-nous » avec un formulaire de 4 champs : nom, email, sujet (select), message. Pas de validation cross-field, pas d'async, juste les classiques required / email. L'équipe choisit Template-driven Forms.

Le composant TypeScript fait 15 lignes : un objet contact = { name: '', email: '', subject: 'general', message: '' } lié via [(ngModel)] à chaque champ. Le submit appelle contactService.send(this.contact). Le template gère les messages d'erreur via #nameRef="ngModel" et @if (nameRef.invalid && nameRef.touched). Pas de FormBuilder, pas de FormGroup, pas d'updateOn.

L'équipe a fait le choix conscient : pour ce type de formulaire trivial, Template-driven est plus rapide à écrire et plus lisible. La règle interne : « si tu hésites entre les deux, va en Reactive ; si tu es sûr que c'est trivial, Template-driven est OK ». Cette page représente 5 % du code formulaire de l'app, le reste est en Reactive Forms.

Scénario 2 — Syndic immobilier, formulaire signalement locataire

Un syndic de copropriété propose un portail locataire avec un mini-formulaire de signalement (panne, désordre, dégât) : type de problème (select), description (textarea), photo facultative. Pas de logique métier complexe, et l'UX cible est mobile-first.

Template-driven est cohérent. Le modèle est report = { type: 'leak', description: '', photo: null }. Le [(ngModel)] permet aux locataires moins techniques (les agents administratifs du syndic relisent les PRs) de comprendre instantanément le binding. La validation se fait via required et minlength="20" directement en attributs HTML, ce qui rappelle un formulaire HTML standard.

Le piège évité : le syndic voulait initialement faire un système de catégories conditionnelles complexes (« si type === panne alors champ électricien obligatoire »). L'équipe a refusé et migré ce sous-cas vers Reactive Forms, gardant Template-driven uniquement pour les cas vraiment simples.

Scénario 3 — Industrie, formulaire intake atelier

Un atelier industriel utilise une tablette posée à l'entrée pour saisir les commandes urgentes : numéro de commande, opérateur, priorité (radio buttons), notes. 4 champs, pas de logique. Le formulaire est utilisé 30 fois par jour par des techniciens qui ne sont pas des utilisateurs Tech-savvy.

Template-driven est idéal. L'équipe a même poussé jusqu'à name="orderNumber" required côté template, ce qui permet de valider sans même charger l'app Angular si JavaScript est désactivé (mode dégradé). Le bouton submit est gris tant que form.invalid, et un message d'erreur s'affiche par champ.

La leçon : pour un formulaire qui a 90 % de chances de ne jamais évoluer vers une logique complexe, Template-driven économise du code et reste maintenable. Si la complexité arrive, on migre (c'est ~30 min de travail pour ce volume).


🛠️ Exemple end-to-end

Use case : formulaire de contact e-commerce, 100 % Template-driven, avec validation HTML5, message de succès, et un test d'intégration.

ts
// contact.model.ts
export interface ContactModel {
  name: string;
  email: string;
  subject: 'general' | 'order' | 'partnership' | 'press';
  message: string;
}
ts
// contact.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { ContactModel } from './contact.model';
import { Observable } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class ContactService {
  private readonly http = inject(HttpClient);

  send(payload: ContactModel): Observable<{ ticketId: string }> {
    return this.http.post<{ ticketId: string }>('/api/contact', payload);
  }
}
ts
// contact.component.ts
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { FormsModule, NgForm } from '@angular/forms';
import { ContactService } from './contact.service';
import { ContactModel } from './contact.model';

@Component({
  selector: 'app-contact',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [FormsModule],
  template: `
    @if (sent()) {
      <p class="success">Merci ! Votre message a bien été envoyé (ticket {{ ticketId() }}).</p>
    } @else {
      <form #f="ngForm" (ngSubmit)="submit(f)" novalidate>
        <label>
          Nom
          <input
            name="name"
            [(ngModel)]="model.name"
            required
            maxlength="80"
            #nameRef="ngModel"
          />
          @if (nameRef.invalid && nameRef.touched) {
            <small class="error">Nom requis.</small>
          }
        </label>

        <label>
          Email
          <input
            type="email"
            name="email"
            [(ngModel)]="model.email"
            required
            email
            #emailRef="ngModel"
          />
          @if (emailRef.invalid && emailRef.touched) {
            <small class="error">Email invalide.</small>
          }
        </label>

        <label>
          Sujet
          <select name="subject" [(ngModel)]="model.subject" required>
            <option value="general">Question générale</option>
            <option value="order">Suivi de commande</option>
            <option value="partnership">Partenariat</option>
            <option value="press">Presse</option>
          </select>
        </label>

        <label>
          Message
          <textarea
            name="message"
            [(ngModel)]="model.message"
            required
            minlength="20"
            #msgRef="ngModel"
          ></textarea>
          @if (msgRef.invalid && msgRef.touched) {
            <small class="error">Message trop court (20 caractères min).</small>
          }
        </label>

        <button type="submit" [disabled]="f.invalid || submitting()">
          {{ submitting() ? 'Envoi…' : 'Envoyer' }}
        </button>
      </form>
    }
  `,
})
export class ContactComponent {
  private readonly contact = inject(ContactService);

  protected model: ContactModel = {
    name: '',
    email: '',
    subject: 'general',
    message: '',
  };

  protected readonly sent = signal(false);
  protected readonly submitting = signal(false);
  protected readonly ticketId = signal<string>('');

  submit(form: NgForm): void {
    if (form.invalid) return;
    this.submitting.set(true);
    this.contact.send(this.model).subscribe({
      next: (res) => {
        this.ticketId.set(res.ticketId);
        this.sent.set(true);
        this.submitting.set(false);
      },
      error: () => this.submitting.set(false),
    });
  }
}
ts
// contact.component.spec.ts
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { ContactComponent } from './contact.component';

it('submits when fields are valid', fakeAsync(() => {
  const fixture = TestBed.configureTestingModule({
    imports: [ContactComponent],
    providers: [provideHttpClient(), provideHttpClientTesting()],
  }).createComponent(ContactComponent);
  fixture.detectChanges();

  const cmp = fixture.componentInstance as any;
  cmp.model = {
    name: 'Alice',
    email: '[email protected]',
    subject: 'general',
    message: 'Bonjour, ceci est un message de test suffisamment long.',
  };
  fixture.detectChanges();
  tick();

  const form = fixture.nativeElement.querySelector('form') as HTMLFormElement;
  form.dispatchEvent(new Event('submit'));

  const ctrl = TestBed.inject(HttpTestingController);
  const req = ctrl.expectOne('/api/contact');
  req.flush({ ticketId: 'T-123' });
  tick();
  fixture.detectChanges();

  expect(fixture.nativeElement.textContent).toContain('Merci');
  expect(fixture.nativeElement.textContent).toContain('T-123');
}));

Le formulaire complet tient en moins de 100 lignes de composant (template inclus). La validation est déclarative en HTML, le binding [(ngModel)] est direct, et le service est injecté avec un seul appel HTTP. Pour ce genre de formulaire, Template-driven est plus court et plus lisible que Reactive.


🔁 Quand utiliser / éviter

Utiliser Template-driven forms quand :

  • Le formulaire a 1 à 4 champs, sans logique cross-field.
  • Aucun async validator non trivial.
  • Le payload est suffisamment simple pour que model: ContactModel direct fasse foi (pas besoin de transformations).
  • L'équipe ou le contributeur préfère écrire le HTML d'abord.
  • Cas typiques : login, recherche header, formulaire de contact statique, filtres rapides.

Migrer vers Reactive Forms quand :

  • On ajoute un validator cross-field, un FormArray dynamique, un async validator avec debounce.
  • On veut tester la logique sans toucher au DOM.
  • Le payload diffère du modèle UI (transformations).
  • Plusieurs équipes éditent le formulaire et on veut un schéma centralisé.

Éviter Template-driven forms pour :

  • Tout formulaire critique (paiement, inscription, formulaires d'admin avec règles).
  • Les formulaires conditionnels où la structure change selon des choix utilisateur.
  • Les formulaires testés intensivement.

Cas limites où template-driven reste pertinent

Quelques scénarios où l'on garde sciemment template-driven :

  • Formulaires générés : si un CMS ou un schéma déclaratif produit le HTML, écrire des [(ngModel)] dans le template généré est plus naturel que de reconstituer un FormGroup côté code.
  • Apps low-code : les outils qui exposent des templates éditables par non-développeurs préfèrent ngModel parce qu'il n'y a pas de couche TypeScript à maintenir en parallèle.
  • Prototypes jetables : un POC qu'on jettera dans deux semaines n'a pas besoin de la cérémonie Reactive.

En 2026, ces cas restent minoritaires dans une codebase d'entreprise, mais ils existent. Ne pas considérer template-driven comme "obsolète" — c'est un outil avec son terrain de prédilection.

Migration template → reactive

La migration suit toujours le même chemin :

  1. Geler le modèle. On garde l'interface TypeScript existante (ContactModel), c'est elle qui guide la structure du FormGroup.
  2. Construire le FormGroup typé côté composant avec NonNullableFormBuilder. Chaque clé du modèle devient un contrôle ; chaque sous-objet devient un FormGroup imbriqué.
  3. Remplacer les directives template : [(ngModel)]="model.x"formControlName="x" ; ngModelGroup="address"formGroupName="address" ; <form #f="ngForm"><form [formGroup]="form">.
  4. Retirer les attributs validators HTML (required, email, etc.) et les déplacer en Validators.required, Validators.email, etc. dans le builder.
  5. Convertir les directives validators custom en ValidatorFn simples. Une directive appForbiddenName devient une fonction forbiddenNameValidator().
  6. Adapter la soumission : submit(f: NgForm)submit() lisant this.form.getRawValue().
  7. Mettre à jour les tests : remplacer les TestBed/fakeAsync par des tests de classe pure.

Exemple concret de migration

Un avant / après illustre le mapping :

html
<!-- AVANT (template-driven) -->
<form #f="ngForm" (ngSubmit)="submit(f)">
  <input name="email" [(ngModel)]="model.email" required email />
</form>

<!-- APRÈS (reactive) -->
<form [formGroup]="form" (ngSubmit)="submit()">
  <input formControlName="email" />
</form>

Et côté composant, le model: ContactModel est remplacé par un form: FormGroup<ContactForm> typé. Les validators HTML disparaissent au profit de Validators.required, Validators.email injectés au builder. La quantité totale de code change peu — c'est la nature qui change.

Le pré-requis pour que la migration soit confortable : avoir une couverture de tests E2E ou d'intégration sur le parcours métier avant de migrer. La migration touche le template et la classe en même temps ; sans filet de sécurité, on découvre les régressions en QA.


🤖 Stack integration — un form template-driven qui pilote un agent IA

Cas réel de cette stack : un formulaire de contact/ticket template-driven (parce qu'il est simple) dont le champ message est assisté par un LLM — l'utilisateur tape une description floue, clique « Reformuler », et un agent Claude (servi par votre back NestJS) renvoie un texte clair en streaming. Le form reste template-driven ; seule la zone de rédaction devient une mini-UI de streaming. C'est exactement le genre de couplage où template-driven brille : la structure est triviale, mais un champ a un comportement riche.

Le piège de débutant : faire [(ngModel)]="model.message" ET réécrire model.message token par token depuis le stream — le banana et le stream se battent pour la même propriété, le curseur saute, et la moindre frappe écrase les tokens en vol. La bonne architecture : séparer le buffer de streaming du modèle de form, et ne committer le résultat dans ngModel qu'à la fin (ou via un bouton « Utiliser ce texte »).

ts
// rewrite-field.component.ts — zone de rédaction assistée, intégrée dans un form template-driven
import {
  ChangeDetectionStrategy, Component, input, linkedSignal, output, signal,
} from '@angular/core';

type RewriteState = 'idle' | 'streaming' | 'done' | 'error';

@Component({
  selector: 'app-rewrite-field',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush, // zoneless-friendly : tout passe par signals
  template: `
    <textarea
      [value]="draft()"
      (input)="draft.set($any($event.target).value)"
      [attr.aria-busy]="state() === 'streaming'"
      rows="6"
    ></textarea>

    @switch (state()) {
      @case ('streaming') {
        <button type="button" (click)="stop()">Stop</button>
        <span class="streaming-preview">{{ buffer() }}</span>
      }
      @case ('done') {
        <button type="button" (click)="apply()">Utiliser ce texte</button>
      }
      @case ('error') {
        <small class="error">Échec de la reformulation. Réessayez.</small>
      }
    }

    <button type="button" (click)="rewrite()" [disabled]="state() === 'streaming'">
      Reformuler avec l'IA
    </button>
  `,
})
export class RewriteFieldComponent {
  /** valeur initiale fournie par le parent (signal input) */
  readonly initial = input.required<string>({ alias: 'draft' });
  /** copie locale éditable, re-seedée si le parent change la valeur initiale */
  protected readonly draft = linkedSignal(() => this.initial());
  /** émis une seule fois au commit du texte reformulé */
  readonly applied = output<string>();

  protected readonly buffer = signal('');           // tokens en vol, séparés du modèle de form
  protected readonly state = signal<RewriteState>('idle');
  private controller: AbortController | null = null;

  async rewrite(): Promise<void> {
    this.controller?.abort();                        // idempotent : on annule un stream précédent
    this.controller = new AbortController();
    this.buffer.set('');
    this.state.set('streaming');

    try {
      const res = await fetch('/api/rewrite', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ text: this.draft() }),
        signal: this.controller.signal,             // annulation client → annule aussi le serveur
      });
      if (!res.body) throw new Error('no stream');

      const reader = res.body.getReader();
      const decoder = new TextDecoder();
      // rAF-coalesced : on n'appelle pas .set() à chaque token, on accumule et on flush par frame
      let pending = '';
      let scheduled = false;
      const flush = () => {
        scheduled = false;
        this.buffer.update((b) => b + pending);
        pending = '';
      };

      while (true) {
        const { value, done } = await reader.read();
        if (done) break;
        pending += decoder.decode(value, { stream: true });
        if (!scheduled) { scheduled = true; requestAnimationFrame(flush); }
      }
      flush();
      this.state.set('done');
    } catch (e) {
      // AbortError = annulation volontaire (Stop / nouveau rewrite), pas une erreur à afficher
      this.state.set((e as Error).name === 'AbortError' ? 'idle' : 'error');
    }
  }

  stop(): void {
    this.controller?.abort();                         // annule le fetch → le serveur reçoit le disconnect
    this.state.set('idle');
  }

  apply(): void {
    this.applied.emit(this.buffer());                 // committe dans le modèle de form, une seule fois
    this.state.set('idle');
  }
}

Et dans le form template-driven parent, on ne touche ngModel qu'au commit :

html
<form #f="ngForm" (ngSubmit)="submit(f)" novalidate>
  <textarea name="message" [(ngModel)]="model.message" required minlength="20" hidden></textarea>

  <app-rewrite-field
    [draft]="model.message"
    (applied)="model.message = $event"
  />

  <button type="submit" [disabled]="f.invalid">Envoyer le ticket</button>
</form>

Points seniors à retenir :

  • Buffer séparé du modèle : le stream alimente buffer (un signal local), jamais ngModel directement. On committe via un output à la fin. Sinon, conflit de curseur et écrasement de frappes.
  • rAF-coalescing : sous zoneless + OnPush, signal.set() par token = un cycle de CD par token = jank. On accumule et on flush une fois par frame (requestAnimationFrame). Pour un débit Claude typique (dizaines de tokens/s), c'est invisible mais 10× moins de travail.
  • AbortController des deux côtés : signal: controller.signal sur le fetch annule la requête réseau ; côté NestJS, brancher l'AbortSignal de la requête (req.on('close')) sur l'appel SDK Anthropic pour stopper la génération et la facturation dès que le client part. Le bouton Stop n'est pas cosmétique : il coupe le coût.
  • AbortError n'est pas une erreur métier : on le distingue de l'erreur réseau (e.name === 'AbortError') pour ne pas afficher de message rouge sur une annulation volontaire.

Côté NestJS (rappel d'architecture, pas le sujet du fichier) : le endpoint /api/rewrite doit streamer via SSE/ReadableStream, utiliser un client LLM injecté via forRootAsync (jamais new Anthropic() dans un field), poser un rate-limit + cost-guard à l'edge, et propager le disconnect client vers messages.stream({ ... }, { signal }). Modèles cibles selon le besoin : claude-haiku-4-5 pour de la reformulation rapide à faible coût, claude-sonnet-4-6 pour un compromis qualité, claude-opus-4-8 pour la rédaction la plus soignée. S'appuyer sur les retries du SDK plutôt que de les réimplémenter.


🏋️ Exercices

Chaque exercice escalade : implémenter → rendre production-grade → casser puis réparer. Fais-les dans l'ordre.

Exercice 1 — Form de login template-driven typé (implémenter)

Objectif : construire un login email + password template-driven, bouton désactivé tant qu'invalide, messages d'erreur par champ révélés au touched.

Indice/Solution : model = { email: '', password: '' }, [(ngModel)] + name sur chaque input, required email sur l'email, required minlength="8" sur le password, #emailRef="ngModel" pour lire emailRef.touched && emailRef.invalid. À la soumission, si form.invalid, faire Object.values(form.controls).forEach(c => c.markAsTouched()) pour forcer l'affichage des erreurs.

Exercice 2 — Validator cross-field « confirmation de mot de passe » (production-grade)

Objectif : ajouter un champ confirmPassword et valider qu'il égale password. Subtilité : le cross-field doit vivre sur un ngModelGroup, pas sur un input isolé.

Indice/Solution : envelopper les deux champs dans <div ngModelGroup="passwords" #pw="ngModelGroup" appPasswordsMatch>. Écrire une directive appPasswordsMatch qui implémente Validator et compare control.get('password')?.value à control.get('confirm')?.value, retournant { mismatch: true } sinon. Provider NG_VALIDATORS + multi: true. Afficher l'erreur via pw.errors?.['mismatch']. Note staff : c'est exactement le point de bascule où Reactive Forms commence à payer — fais aussi la version reactive et compare le volume de code.

Exercice 3 — Async validator « email disponible » avec updateOn: 'blur' (production-grade)

Objectif : interroger /api/email-available?email=... et invalider l'email s'il est déjà pris, sans spammer l'API à chaque frappe.

Indice/Solution : directive appEmailAvailable implémentant AsyncValidator, validate() retourne un Observable<ValidationErrors | null> (http.get(...).pipe(map(r => r.available ? null : { taken: true }))). Câbler [ngModelOptions]="{ updateOn: 'blur' }" sur l'input pour ne déclencher qu'à la perte de focus. Gérer l'état pending : désactiver le submit tant que form.controls['email'].pending. Bonus : catchError(() => of(null)) pour ne pas bloquer l'utilisateur si l'API tombe (fail-open sur un validator non sécuritaire).

Exercice 4 — Autosave avec valueChanges debouncé (production-grade)

Objectif : sauvegarder un brouillon toutes les 800 ms d'inactivité, sans fuite de souscription.

Indice/Solution : @ViewChild('f') form?: NgForm, dans ngAfterViewInit : this.form?.valueChanges?.pipe(debounceTime(800), distinctUntilChanged((a,b) => JSON.stringify(a)===JSON.stringify(b)), takeUntilDestroyed(this.destroyRef)).subscribe(v => this.draft.save(v)). Piège à reproduire : tenter cette souscription dans ngOnInitform est undefined. Comprendre pourquoi (le NgForm n'existe qu'après le premier rendu).

Exercice 5 — Casse-le puis répare-le : le bug du curseur en streaming (break then fix)

Objectif : reproduire puis corriger le conflit entre [(ngModel)] et un flux de tokens IA.

Indice/Solution : branche volontairement un stream qui fait model.message = model.message + token à chaque token sur un champ [(ngModel)]="model.message", et tape pendant le stream → le curseur saute et tes frappes sont écrasées. Répare en suivant la section Stack integration : buffer signal séparé, rAF-coalescing, commit unique via output au clic « Utiliser ce texte ». Vérifie aussi que le bouton Stop annule bien le fetch (AbortController.abort()) et que l'AbortError n'affiche pas de message d'erreur.

Exercice 6 — Composant custom ControlValueAccessor agnostique (production-grade)

Objectif : écrire un app-star-rating (0-5 étoiles) qui se branche sur [(ngModel)]="model.rating" exactement comme un input natif, accessible au clavier, et qui survit intact à une migration vers Reactive Forms.

Indice/Solution : implémenter ControlValueAccessor avec self-registration sur NG_VALUE_ACCESSOR (useExisting: forwardRef(...), multi: true). Les quatre méthodes : writeValue (modèle→vue), registerOnChange/registerOnTouched (vue→modèle + touched), setDisabledState. Piège volontaire à corriger : oublie registerOnTouched → constate que les erreurs touched ne s'affichent jamais. Bonus a11y : role="radiogroup", navigation flèches, aria-pressed/aria-checked par étoile. Vérifie ensuite que le même composant marche sans modification avec formControlName="rating" — c'est le test qui prouve l'agnosticisme.

Exercice 7 — Migration template → reactive sous filet de tests (refactor)

Objectif : migrer l'Exercice 2 (login + cross-field) vers Reactive Forms sans régression.

Indice/Solution : écris d'abord 3 tests d'intégration sur le parcours (invalide au départ, erreur mismatch visible, submit OK quand valide). Puis applique le mapping de la section Migration : [(ngModel)]formControlName, ngModelGroupformGroupName, directive appPasswordsMatchValidatorFn posée sur le FormGroup via NonNullableFormBuilder. Les tests passent du style fakeAsync/DOM au style classe pure — c'est le gain à mesurer. Note : si tu as fait l'Exercice 6, ton app-star-rating doit traverser la migration sans une seule ligne changée — vérifie-le.


🎤 En entretien

Q : Pourquoi f.value est-il typé any en template-driven, et qu'est-ce que ça implique en pratique ? R : Parce que les contrôles sont créés dynamiquement au rendu à partir des name= du template — Angular n'a aucune information statique pour inférer une forme. En pratique, on ne soumet jamais f.value ; on soumet le model typé et on n'utilise f que pour f.invalid/f.resetForm(). C'est précisément cette perte de typage qui justifie Reactive Forms dès que les contrôles se multiplient.

Q : Pourquoi ne peut-on pas souscrire à f.valueChanges dans ngOnInit ? R : Le NgForm et ses NgModel enfants sont instanciés pendant le rendu du template, donc après ngOnInit. La variable @ViewChild('f') n'est résolue qu'à ngAfterViewInit. En Reactive Forms le FormGroup existe dès le constructeur, ce qui supprime cette friction de timing — un argument structurel, pas cosmétique.

Q : Comment intègre-tu un champ assisté par LLM en streaming dans un form template-driven sans corrompre la saisie ? R : Je sépare le buffer de streaming (un signal local) du modèle de form. Le stream alimente le buffer, jamais ngModel directement ; je committe dans ngModel en une seule fois via un output quand l'utilisateur valide. Sous zoneless/OnPush je coalesce les set() par requestAnimationFrame pour éviter un cycle de CD par token, et je câble un AbortController côté client et serveur pour que Stop coupe la génération — et le coût.

Q : Comment [(ngModel)] parle-t-il à un <input> ? Et comment branches-tu un composant maison dessus ? R : Via un ControlValueAccessor. ngModel ne touche jamais le DOM directement — il dialogue avec un CVA enregistré sur NG_VALUE_ACCESSOR ; pour un input natif c'est le DefaultValueAccessor built-in. Pour un composant maison, j'implémente le contrat à quatre méthodes (writeValue, registerOnChange, registerOnTouched, setDisabledState) et je m'auto-enregistre avec useExisting/multi: true. L'oubli classique est registerOnTouched (les erreurs ne s'affichent plus) ou setDisabledState. Avantage : un CVA bien écrit est agnostique de l'API de forms — il survit intact à une migration template→reactive.

Q : La validation required/pattern du template suffit-elle pour la sécurité ? R : Non, jamais. La validation client est de l'UX — contournable en deux secondes via DevTools ou curl. La vérité est côté serveur : un DTO validé (class-validator côté NestJS) qui rejette toute payload non conforme, indépendamment du front. Le form valide pour guider l'utilisateur ; le backend valide pour protéger le système. Et aucune donnée sensible (prix, rôle, autorisation) ne se décide à partir de f.value.

Q : Template-driven, Reactive ou Signal Forms en 2026 — comment tu choisis ? R : La question revient à « où doit vivre la source de vérité ». Template-driven (vue) pour 1-4 champs sans logique transverse. Reactive (classe) dès qu'il y a cross-field, async non trivial, FormArray dynamique, ou besoin de tests en classe pure. Signal Forms (signal) est la direction expérimentale signal-first/zoneless — à connaître et citer, mais pas en prod critique tant que l'API n'est pas stabilisée.


🔗 Liens

  • Template-driven forms — angular.dev/guide/forms/template-driven-forms
  • NgForm API — angular.dev/api/forms/NgForm
  • NgModel API — angular.dev/api/forms/NgModel
  • Custom validator directive — angular.dev/guide/forms/form-validation#custom-validators
  • Comparaison template vs reactive — section "Choosing an approach" du guide officiel

Récap final

Les template-driven forms construisent l'arbre de contrôles depuis le HTML : ngModel crée silencieusement un FormControl, ngForm agrège dans un FormGroup, ngModelGroup imbrique des sous-objets. Les validators built-in s'activent par attribut HTML, les customs requièrent une directive Validator. Le banana [(ngModel)] reste l'API par défaut ; (ngModelChange) sert à intercepter. La valeur agrégée (f.value) est non typée — on travaille via le model typé. Les tests requièrent TestBed, fakeAsync et des événements DOM réels. À choisir pour les petits formulaires sans logique transverse ; à éviter dès qu'on a du cross-field, de l'async, du dynamique. La migration vers Reactive Forms est mécanique mais doit se faire avant d'accumuler la complexité, pas après.

Bibliothèque tech perso — Achref