Avez-vous une question? (216) 97 656 803 hajjriadh@gmail.com
Cours

Le two-way data binding et ngModel

Maîtrisez la liaison bidirectionnelle d’Angular : comprendre ce que cache la « banane dans une boîte » [( )], lier tous les champs de formulaire avec ngModel, contrôler la saisie avec la forme longue,...

0 chapitres 0 QCM Accès gratuit
Dans cet article

À propos de ce cours

Maîtrisez la liaison bidirectionnelle d’Angular : comprendre ce que cache la « banane dans une boîte » [( )], lier tous les champs de formulaire avec ngModel, contrôler la saisie avec la forme longue, valider un formulaire avec ngForm, et rendre vos propres composants compatibles grâce à model().
  1. Objectifs

    • Comprendre le fonctionnement de la liaison bidirectionnelle [( )]: une liaison de propriété et une liaison d'événement
    • Lier tous les types de champs de formulaire avec ngModel, et savoir quel type de valeur chacun produit
    • Utiliser la forme longue [ngModel] + (ngModelChange) pour transformer ou contrôler une saisie
    • Régler le moment de la mise à jour avec ngModelOptions, et utiliser ngModel dans un <form>
    • Exploiter l'état et la validation d'un champ et d'un formulaire (ngModel, ngForm)
    • Créer vos propres composants compatibles avec [( )] grâce à model(), et éviter les pièges des objets et des tableaux
  1. Présentation

    • Dans un formulaire, une donnée circule dans les deux sens: la classe fournit la valeur de départ au champ, puis chaque saisie de l'utilisateur doit revenir dans la classe. C'est le rôle de la liaison bidirectionnelle (two-way data binding).
    • Sa syntaxe [( )], surnommée la « banane dans une boîte », n'est qu'un raccourci : [(ngModel)]="nom" équivaut à [ngModel]="nom()" (afficher) suivi de (ngModelChange)="nom.set($event)" (récupérer la saisie).
    • ngModel est une directive du module FormsModule (paquet @angular/forms). Elle fonctionne directement avec un signal modifiable (signal()) comme avec une propriété ordinaire.

[(ngModel)] : une liaison de propriété et une liaison d'événement

  • Pour suivre ce chapitre, continuez dans le projet premiers-pas-angular et créez un dossier src/app/twoway/. Tous les exemples ont été testés avec Angular 22.
  • Rappel : sans FormsModule dans les imports du composant, la compilation échoue : NG8002: Can't bind to 'ngModel' since it isn't a known property of 'input'.
  1. ngModel sur tous les types de champs

    • ngModel s'adapte au champ sur lequel il est placé : texte, nombre, curseur, case à cocher, boutons radio, liste déroulante simple ou multiple, zone de texte. Le type de la valeur reçue dépend du champ.

Le type de valeur produit par ngModel selon le champ

import { Component, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { JsonPipe } from '@angular/common';

interface Ville {
  code: string;
  nom: string;
}

@Component({
  selector: 'app-types-champs',
  imports: [FormsModule, JsonPipe],
  template: `
    <label>Nom <input [(ngModel)]="nom" /></label>
    <label>Âge <input type="number" [(ngModel)]="age" /></label>
    <label>Niveau <input type="range" min="1" max="5" [(ngModel)]="niveau" /></label>
    <label><input type="checkbox" [(ngModel)]="newsletter" /> Newsletter</label>
    @for (f of ['matin', 'soir']; track f) {
      <label><input type="radio" name="creneau" [value]="f" [(ngModel)]="creneau" /> {{ f }}</label>
    }
    <label>Ville
      <select [(ngModel)]="ville" [compareWith]="memeVille">
        @for (v of villes; track v.code) {
          <option [ngValue]="v">{{ v.nom }}</option>
        }
      </select>
    </label>
    <label>Langages
      <select multiple [(ngModel)]="langages">
        @for (l of ['TypeScript', 'Python', 'Java']; track l) {
          <option [value]="l">{{ l }}</option>
        }
      </select>
    </label>
    <label>Bio <textarea [(ngModel)]="bio"></textarea></label>

    <pre>{{ { nom: nom(), age: age(), niveau: niveau(), newsletter: newsletter(), creneau: creneau(), ville: ville(), langages: langages(), bio: bio() } | json }}</pre>
    <p>typeof age : {{ typeof age() }} – typeof niveau : {{ typeof niveau() }}</p>
  `,
})
export class TypesChamps {
  protected readonly villes: Ville[] = [
    { code: 'TUN', nom: 'Tunis' },
    { code: 'SOU', nom: 'Sousse' },
  ];
  protected readonly nom = signal('Amira');
  protected readonly age = signal(21);
  protected readonly niveau = signal(3);
  protected readonly newsletter = signal(true);
  protected readonly creneau = signal('soir');
  // Objet différent de villes[1] mais de même code : compareWith le reconnaît
  protected readonly ville = signal<Ville>({ code: 'SOU', nom: 'Sousse' });
  protected readonly langages = signal<string[]>(['TypeScript']);
  protected readonly bio = signal('');

  protected memeVille(a: Ville | null, b: Ville | null): boolean {
    return a?.code === b?.code;
  }
}
{ "nom": "Amira", "age": 21, "niveau": 3, "newsletter": true, "creneau": "soir",
  "ville": { "code": "SOU", "nom": "Sousse" }, "langages": ["TypeScript"], "bio": "" }
typeof age : number – typeof niveau : number
(après avoir saisi Sami et 25, poussé le curseur à 5, décoché, choisi matin, Tunis, TypeScript + Java et Étudiant)
{ "nom": "Sami", "age": 25, "niveau": 5, "newsletter": false, "creneau": "matin",
  "ville": { "code": "TUN", "nom": "Tunis" }, "langages": ["TypeScript", "Java"], "bio": "Étudiant" }
(après avoir vidé le champ Âge)
typeof age : object       ← la valeur est null
  • Sur type="number" et type="range", ngModel fournit un vrai nombre. Un champ numérique vide donne null: déclarez le signal signal<number | null>(…) si ce cas doit être géré.
  • Les boutons radio d'un même groupe partagent le même name et le même signal ; chacun fournit sa valeur par [value].
  • [value] sur une option transmet toujours une chaîne; [ngValue] transmet n'importe quelle valeur, notamment un objet.
  • [compareWith]: la ville de départ est un objet différent de villes[1], même s'il a le même contenu (c'est souvent le cas d'une donnée reçue d'un serveur). Sans compareWith, Angular compare les objets par référence : aucune option n'est sélectionnée (selectedIndex vaut -1). La fonction memeVille compare les codes.
  1. La forme longue : [ngModel] + (ngModelChange)

    • Quand la saisie doit être transformée, limitée ou déclencher un traitement, on sépare les deux moitiés : [ngModel] affiche la valeur, (ngModelChange) reçoit la saisie dans $event et décide quoi en faire.
import { Component, computed, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';

@Component({
  selector: 'app-forme-longue',
  imports: [FormsModule],
  template: `
    <label>Code postal
      <input [ngModel]="codePostal()" (ngModelChange)="saisirCode($event)" maxlength="4" />
    </label>
    <label>Immatriculation
      <input [ngModel]="plaque()" (ngModelChange)="plaque.set($event.toUpperCase())" />
    </label>
    <label>Quantité (1 à 10)
      <input type="number" [ngModel]="quantite()" (ngModelChange)="changerQuantite($event)" />
    </label>
    <p>Code : {{ codePostal() || '—' }} – plaque : {{ plaque() || '—' }} – quantité : {{ quantite() }} – {{ historique() }}</p>
  `,
})
export class FormeLongue {
  protected readonly codePostal = signal('');
  protected readonly plaque = signal('');
  protected readonly quantite = signal(1);
  protected readonly modifications = signal(0);
  protected readonly historique = computed(() => `${this.modifications()} modification(s)`);

  protected saisirCode(valeur: string): void {
    this.codePostal.set(valeur.replace(/\D/g, ''));
  }

  protected changerQuantite(valeur: number | null): void {
    this.quantite.set(Math.min(10, Math.max(1, valeur ?? 1)));
    this.modifications.update((n) => n + 1);
  }
}
(saisie de lettres et de chiffres dans le code postal, de « 123 tu 4567 », puis de 25 en quantité)
Code : 405 – plaque : 123 TU 4567 – quantité : 10 – 1 modification(s)
  • Les lettres tapées dans le code postal disparaissent aussitôt, la plaque passe en majuscules pendant la frappe, et une quantité de 25 est ramenée à 10.
  • (ngModelChange) se déclenche à chaque modification du champ, comme (input), mais fournit la valeur déjà convertie (un nombre pour type="number") au lieu de l'objet événement.
  • Attention : si la valeur corrigée est identique à la valeur actuelle du signal, le champ n'est pas réécrit. Avec une quantité déjà à 10, taper 30 laisse 30 affiché dans le champ alors que le signal vaut toujours 10. Pour une limite stricte, préférez les attributs min / max et la validation (voir plus bas).
  1. Le moment de la mise à jour : ngModelOptions

    • Par défaut, la donnée est mise à jour à chaque frappe. [ngModelOptions]="{ updateOn: 'blur' }" attend que l'utilisateur quitte le champ ; updateOn: 'submit' attend l'envoi du formulaire.
    • Dans une balise <form>, chaque champ ngModel est enregistré dans le formulaire sous son name, qui devient obligatoire. Un champ qui ne doit pas faire partie du formulaire reçoit { standalone: true }.

updateOn change, blur et submit, et ngModel dans un formulaire

import { Component, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';

@Component({
  selector: 'app-options',
  imports: [FormsModule],
  template: `
    <label>À chaque frappe <input [(ngModel)]="direct" /></label>
    <label>À la sortie du champ <input [(ngModel)]="sortie" [ngModelOptions]="{ updateOn: 'blur' }" /></label>
    <p>direct : « {{ direct() }} » – sortie : « {{ sortie() }} »</p>

    <form (ngSubmit)="envoye.set(recherche())">
      <input name="recherche" [(ngModel)]="recherche" [ngModelOptions]="{ updateOn: 'submit' }" placeholder="Rechercher" />
      <label><input type="checkbox" [(ngModel)]="avance" [ngModelOptions]="{ standalone: true }" /> Recherche avancée</label>
      <button>Chercher</button>
    </form>
    <p>recherche : « {{ recherche() }} » – envoyé : « {{ envoye() }} » – avancée : {{ avance() }}</p>
  `,
})
export class Options {
  protected readonly direct = signal('');
  protected readonly sortie = signal('');
  protected readonly recherche = signal('');
  protected readonly envoye = signal('');
  protected readonly avance = signal(false);
}
(après avoir tapé abc et xyz, sans quitter le second champ)
direct : « abc » – sortie : « »
(après avoir quitté le second champ)
direct : « abc » – sortie : « xyz »
(après avoir tapé angular et coché la case, avant l'envoi)
recherche : « » – envoyé : « » – avancée : true
(après un clic sur Chercher)
recherche : « angular » – envoyé : « angular » – avancée : true
  • updateOn: 'blur' évite des calculs à chaque frappe (recherche dans une longue liste, vérification coûteuse) ; updateOn: 'submit' ne prend la saisie en compte qu'au moment de l'envoi.
  • Quand FormsModule est importé, Angular prend en charge chaque balise <form>: (ngSubmit) remplace (submit) et la page n'est pas rechargée.
  • Attention : un ngModel sans name dans un <form> provoque une erreur dans la console : NG01352: 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.
  1. L'état et la validation d'un champ

    • Chaque champ ngModel suit son état: valide ou non, modifié ou non (dirty / pristine), quitté ou non (touched / untouched). Une variable de référence #champ="ngModel" y donne accès dans le template.
    • Les règles de validation s'écrivent comme des attributs HTML : required, minlength, maxlength, email, pattern, et min / max pour les nombres.
    • Angular ajoute aussi des classes CSS à chaque champ (ng-valid, ng-invalid, ng-touched, ng-dirty…) : on peut les styler directement.

États, classes CSS et règles de validation d'un champ ngModel

import { Component, signal } from '@angular/core';
import { FormsModule, NgForm } from '@angular/forms';

@Component({
  selector: 'app-validation',
  imports: [FormsModule],
  template: `
    <form #f="ngForm" (ngSubmit)="envoyer(f)" novalidate>
      <label>Nom
        <input name="nom" [(ngModel)]="nom" #champNom="ngModel" required minlength="3" />
      </label>
      @if (champNom.invalid && champNom.touched) {
        @if (champNom.hasError('required')) { <small>Le nom est obligatoire.</small> }
        @if (champNom.hasError('minlength')) {
          <small>Au moins {{ champNom.errors?.['minlength'].requiredLength }} caractères
            ({{ champNom.errors?.['minlength'].actualLength }} saisis).</small>
        }
      }
      <label>E-mail
        <input name="email" type="email" [(ngModel)]="email" #champEmail="ngModel" required email />
      </label>
      @if (champEmail.invalid && champEmail.touched) { <small>E-mail invalide.</small> }

      <p class="etat">nom : {{ champNom.valid ? 'valide' : 'invalide' }}, {{ champNom.touched ? 'touché' : 'non touché' }},
        {{ champNom.dirty ? 'modifié' : 'intact' }} – formulaire : {{ f.valid ? 'valide' : 'invalide' }}</p>
      <button [disabled]="f.invalid">Envoyer</button>
      <button type="button" (click)="f.resetForm({ nom: '', email: '' })">Effacer</button>
    </form>
    <p>{{ message() }}</p>
  `,
  styles: `
    input.ng-invalid.ng-touched { border: 2px solid #dc2626; }
    input.ng-valid.ng-dirty { border: 2px solid #16a34a; }
    small { display: block; color: #dc2626; }
  `,
})
export class Validation {
  protected readonly nom = signal('');
  protected readonly email = signal('');
  protected readonly message = signal('');

  protected envoyer(f: NgForm): void {
    this.message.set(`Envoyé : ${JSON.stringify(f.value)}`);
  }
}
nom : invalide, non touché, intact – formulaire : invalide        ← au chargement, aucun message
(après avoir saisi Sa et sami@, puis quitté les champs)
Au moins 3 caractères (2 saisis).
E-mail invalide.
nom : invalide, touché, modifié – formulaire : invalide
classes du champ nom : ng-invalid ng-dirty ng-touched                ← bordure rouge
(après avoir saisi Sami et sami@mail.tn, puis cliqué sur Envoyer)
Envoyé : {"nom":"Sami","email":"sami@mail.tn"}
(après un clic sur Effacer)
nom : invalide, non touché, intact – formulaire : invalide
  • On n'affiche les erreurs qu'après touched: l'utilisateur n'est pas accueilli par des messages rouges avant d'avoir rien saisi.
  • #f="ngForm" donne accès au formulaire entier: f.valid, f.invalid, f.value (un objet dont les clés sont les name). novalidate désactive les bulles de validation du navigateur, au profit des messages d'Angular.
  • f.resetForm({ … }) remet les valeurs et les états à zéro : les champs redeviennent « non touchés », donc les messages d'erreur disparaissent.
  • errors peut valoir null et ses clés se lisent entre crochets : champNom.errors?.['minlength'].requiredLength. Écrire champNom.errors.minlength donne TS2531: Object is possibly 'null'. et TS4111: Property 'minlength' comes from an index signature, so it must be accessed with ['minlength'].
  1. [( )] sur vos propres composants : model()

    • La liaison bidirectionnelle n'est pas réservée à ngModel. Un composant qui déclare une entrée avec model() accepte [(nom)]: le parent fournit la valeur, l'enfant peut la modifier avec set() ou update(), et Angular émet automatiquement l'événement nomChange.

model() : une entrée modifiable synchronisée avec le parent

import { Component, booleanAttribute, input, model } from '@angular/core';

@Component({
  selector: 'app-interrupteur',
  template: `
    <button type="button" role="switch" [attr.aria-checked]="actif()" [disabled]="desactive()"
            (click)="actif.set(!actif())" [class.on]="actif()">
      <span class="bouton"></span>
    </button>
    <ng-content />
  `,
  styles: `
    :host { display: inline-flex; align-items: center; gap: 8px; margin-right: 14px; }
    button { width: 44px; height: 24px; border-radius: 12px; border: 0; background: #d1d5db; position: relative; padding: 0; }
    button.on { background: #16a34a; }
    .bouton { position: absolute; top: 3px; left: 3px; width: 18px; height: 18px; border-radius: 50%; background: #fff; transition: left .2s; }
    .on .bouton { left: 23px; }
  `,
})
export class Interrupteur {
  readonly actif = model(false);
  readonly desactive = input(false, { transform: booleanAttribute });
}
import { Component, signal } from '@angular/core';
import { Interrupteur } from './interrupteur';

@Component({
  selector: 'app-reglages',
  imports: [Interrupteur],
  template: `
    <app-interrupteur [(actif)]="sombre">Thème sombre</app-interrupteur>
    <app-interrupteur [actif]="notifications()" (actifChange)="notifications.set($event)">Notifications</app-interrupteur>
    <app-interrupteur [actif]="true" desactive>Sauvegarde automatique</app-interrupteur>
    <p>sombre : {{ sombre() }} – notifications : {{ notifications() }}</p>
    <button (click)="sombre.set(false); notifications.set(false)">Tout désactiver</button>
  `,
})
export class Reglages {
  protected readonly sombre = signal(false);
  protected readonly notifications = signal(true);
}
sombre : false – notifications : true        aria-checked : false, true, true (le 3e est désactivé)
(après un clic sur les deux premiers interrupteurs)
sombre : true – notifications : false
(après un clic sur Tout désactiver)
sombre : false – notifications : false       les interrupteurs suivent
  • Les deux premières écritures sont équivalentes : [(actif)]="sombre" et [actif]="notifications()" (actifChange)="notifications.set($event)".
  • Un model() peut aussi recevoir une valeur fixe ([actif]="true") : l'enfant la modifie alors pour lui seul, sans rien renvoyer d'utile au parent.
  • role="switch" et [attr.aria-checked] rendent l'interrupteur compréhensible par les lecteurs d'écran.
  • Attention : [( )] sur une entrée déclarée avec input() est refusé : NG8007: The property and event halves of the two-way binding 'actif' are not bound to the same target.
  • Pour qu'un composant personnalisé fonctionne avec ngModel (et participe à la validation d'un ngForm), il doit implémenter l'interface ControlValueAccessor: ce sujet sera traité dans le chapitre consacré aux formulaires.
  1. Le piège des objets et des tableaux

    • Un signal ne prévient Angular que lorsqu'on lui donne une nouvelle valeur avec set() ou update(). Or [(ngModel)]="profil().prenom" modifie la propriété de l'objet sur place: le signal ne change pas.

Modifier un objet sur place ou créer un nouvel objet avec update()

import { Component, computed, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';

interface Profil {
  prenom: string;
  ville: string;
}

@Component({
  selector: 'app-piege-objet',
  imports: [FormsModule],
  template: `
    <!-- À éviter : modifie l'objet du signal sans prévenir Angular -->
    <label>Prénom (piège) <input [(ngModel)]="profilA().prenom" /></label>
    <p>Signal : {{ profilA().prenom }} – computed : {{ salutationA() }}</p>

    <!-- Correct : crée un nouvel objet avec update() -->
    <label>Prénom (correct)
      <input [ngModel]="profilB().prenom" (ngModelChange)="profilB.update((p) => ({ ...p, prenom: $event }))" />
    </label>
    <p>Signal : {{ profilB().prenom }} – computed : {{ salutationB() }}</p>
  `,
})
export class PiegeObjet {
  protected readonly profilA = signal<Profil>({ prenom: 'Amira', ville: 'Sousse' });
  protected readonly salutationA = computed(() => `Bonjour ${this.profilA().prenom} de ${this.profilA().ville}`);
  protected readonly profilB = signal<Profil>({ prenom: 'Amira', ville: 'Sousse' });
  protected readonly salutationB = computed(() => `Bonjour ${this.profilB().prenom} de ${this.profilB().ville}`);
}
(après avoir tapé Sami dans les deux champs)
Signal : Sami – computed : Bonjour Amira de Sousse     ← le computed n'a rien vu
Signal : Sami – computed : Bonjour Sami de Sousse      ← correct
  • Le premier exemple compile sans erreur ni avertissement, ce qui rend le piège difficile à repérer : seules les valeurs dérivées (computed, autres composants qui lisent le signal) restent figées.
  • La règle est la même dans un @for: on ne modifie pas l'élément de la liste, on remplace la liste par une copie modifiée (map + décomposition { ...t, … }). Les activités 3 et 5 appliquent ce principe.
  • Attention : [(ngModel)] sur un computed ne fonctionne pas non plus : il n'est pas modifiable. Le champ affiche alors [Computed (total): 3], sans erreur de compilation. Utilisez la forme longue.
  1. Aide-mémoire

Aide-mémoire du two-way data binding et de ngModel

  • ngModel convient aux formulaires simples, construits dans le template. Pour les formulaires plus riches, Angular propose les formulaires réactifs et, depuis Angular 22, les Signal Forms (form() et [formField]), présentés dans « Le data binding dans Angular ».
  1. Activité 1 : un convertisseur de températures

    • Générer le composant act-twoway/convertisseur avec un seul signal celsius (20 au départ) et trois champs numériques modifiables: Celsius, Fahrenheit et Kelvin.
      • Fahrenheit et Kelvin sont des computed arrondis à un chiffre après la virgule (°F = °C × 9 / 5 + 32 ; K = °C + 273,15) ;
      • chaque champ utilise la forme longue : (ngModelChange) reconvertit la saisie en degrés Celsius, et ignore un champ vide (null) ;
      • un curseur de −30 à 50 est lié à celsius avec [(ngModel)];
      • un bandeau affiche « ❄ Il gèle » (bleu, ≤ 0), « 🌤 Température agréable » (vert, < 30) ou « 🔥 Il fait chaud » (rouge), avec [class].

Résultat attendu de l'activité 1 avec le curseur à 35 °C

  • Saisir 100.4 en Fahrenheit : que deviennent les deux autres champs ? Saisir 273.15 en Kelvin : pourquoi le champ affiche-t-il aussitôt 273.2 ?
  • Pourquoi ne peut-on pas écrire [(ngModel)]="fahrenheit"? Essayer, et décrire ce que montre le champ.
  1. Activité 2 : un éditeur de carte de visite

    • Générer le composant act-twoway/carte-visite: un formulaire à gauche, un aperçu en direct en dessous. Tous les réglages sont des signals liés par [(ngModel)]:
      • nom, poste et e-mail (champs texte) ; couleur principale (type="color") ; police (liste : sans-serif, serif, monospace) ;
      • taille du nom de 16 à 32 px (curseur, valeur affichée dans l'étiquette) ; case « Afficher l'e-mail » ; alignement gauche / centré (boutons radio) ;
      • l'aperçu applique les réglages avec [style.border-color], [style.font-family], [style.text-align], [style.font-size.px], et n'affiche l'e-mail que si la case est cochée (@if).

Résultat attendu de l'activité 2 : carte bleue, police serif, centrée

  • Vider le champ Nom : que montre l'aperçu ? Comment afficher « Votre nom » dans ce cas, avec une seule expression ?
  • Quel type de valeur reçoit le signal taille? Vérifier avec {{ typeof taille() }}.
  1. Activité 3 : un panier avec un composant compteur

    • Générer le composant act-twoway/compteur: deux boutons − et + autour d'une valeur déclarée avec model.required<number>(), et deux entrées min (0) et max (99) avec numberAttribute. Les boutons se désactivent aux limites et portent un aria-label.
    • Générer le composant act-twoway/panier: un tableau de trois articles (Clavier 45 DT ×1, Souris 20 DT ×2, Casque 80 DT ×0) stockés dans un signal, avec :
      • un compteur par ligne (maximum 5), en forme longue : [valeur]="l.quantite" (valeurChange)="changerQuantite(l.id, $event)", qui remplace la ligne sans modifier l'objet d'origine ;
      • le sous-total de chaque ligne, le nombre d'articles et le total (computed), une ligne grisée si sa quantité est nulle ;
      • sous le tableau, un délai de livraison de 1 à 7 jours lié directement avec [(valeur)]="jours".

Résultat attendu de l'activité 3 avec 5 souris et 1 casque

  • Remplacer la forme longue par [(valeur)]="l.quantite": la compilation réussit-elle ? Cliquer sur + pour la souris : la ligne affiche 3 et 60 DT, mais le total reste « 3 article(s), 85 DT ». Expliquer.
  • Pourquoi le bouton + de la souris est-il désactivé à 5 ?
  1. Activité 4 : un formulaire de contact validé

    • Générer le composant act-twoway/contact: un <form #f="ngForm" novalidate> dont chaque champ a un name et une variable de référence #c…="ngModel":
      • nom : obligatoire, au moins 3 caractères ; e-mail : obligatoire et valide ; téléphone : facultatif, mais 8 chiffres s'il est rempli (pattern) ;
      • sujet : liste obligatoire (Inscription, Facturation, Autre) avec une première option vide « Choisir… » ;
      • message : obligatoire, de 20 à 300 caractères, avec un compteur « N / 300 caractères (20 minimum) » qui passe au rouge en cas d'erreur ;
      • chaque erreur s'affiche sous son champ après qu'il a été quitté, et le champ est encadré en rouge grâce aux classes ng-invalid et ng-touched;
      • Envoyer est désactivé tant que le formulaire est invalide ; l'envoi affiche « Merci nom, votre message « sujet » a bien été envoyé. » puis vide le formulaire avec resetForm.

Résultat attendu de l'activité 4 avec des saisies invalides

  • Pourquoi le message de confirmation est-il stocké dans un signal, alors que les valeurs saisies sont relues dans this.nom(), this.sujet()? Que vaut f.value au moment de l'envoi ?
  • Remplacer f.resetForm(…) par this.nom.set(''), etc. : les champs se vident-ils ? Les messages d'erreur réapparaissent-ils ? Pourquoi ?
  1. Activité 5 : une liste de tâches modifiable

    • Générer le composant act-twoway/taches qui gère trois tâches (id, titre, faite) dans un signal :
      • une case à cocher par tâche, en forme longue, qui appelle modifier(id, { faite: $event }); une tâche faite est barrée ;
      • un double-clic sur un titre le remplace par un champ de saisie (forme longue, modifier(id, { titre: $event })), qui se referme avec Entrée ou en quittant le champ ;
      • une case « Tout cocher » : [ngModel]="toutesFaites()" (un computed) et (ngModelChange)="toutCocher($event)";
      • trois boutons radio Toutes / Actives / Terminées liés à un signal filtre par [(ngModel)], et le compteur « N tâche(s) restante(s) sur 3 ».

Résultat attendu de l'activité 5 pendant la modification d'une tâche

  • Cocher les trois tâches une par une : que devient la case « Tout cocher » ? La décocher : que se passe-t-il ?
  • Pourquoi la case « Tout cocher » ne peut-elle pas utiliser [(ngModel)]="toutesFaites"?
  • Faire un commit après chaque activité, puis un push : git commit -m "Two-way binding : activité N".
  1. Erreurs fréquentes

Message ou symptôme Cause Solution
NG8002: Can't bind to 'ngModel' since it isn't a known property of 'input'. FormsModule absent des imports Ajouter FormsModule dans imports
NG5002: Unsupported expression in a two-way binding [(ngModel)]="nom()" ou une valeur fixe [(ngModel)]="'texte'" Passer un signal modifiable sans parenthèses : [(ngModel)]="nom"
NG8007: The property and event halves of the two-way binding 'actif' are not bound to the same target. [( )] sur une entrée input() Déclarer l'entrée avec model()
NG01352: If ngModel is used within a form tag, either the name attribute must be set … (console) Champ ngModel sans name dans un <form> Ajouter name="…" ou [ngModelOptions]="{ standalone: true }"
NG8003: No directive found with exportAs 'ngform'. Faute de casse : ngform #f="ngForm", #c="ngModel" (et FormsModule importé)
TS2531: Object is possibly 'null'. et TS4111: Property 'minlength' comes from an index signature … c.errors.minlength c.errors?.['minlength'], ou c.hasError('minlength')
Liste déroulante vide au chargement alors que le signal contient un objet Objet différent (même contenu) de ceux des options [compareWith] qui compare un identifiant
Le champ affiche [Computed (total): 3] [(ngModel)] sur un computed Forme longue : [ngModel]="total()" (ngModelChange)="…"
Le champ affiche 30 alors que le signal vaut 10 Valeur corrigée identique à la valeur actuelle : le champ n'est pas réécrit Utiliser min / max et la validation
Un computed ou un total ne se met pas à jour après une saisie Objet ou élément de tableau modifié sur place ([(ngModel)]="profil().prenom") update() avec un nouvel objet : { ...p, prenom: $event }
Un champ numérique vidé fait apparaître null ou NaN dans les calculs ngModel fournit null pour un champ vide Typer number | null et tester null
Les messages d'erreur s'affichent dès l'ouverture de la page Condition sur invalid seulement Ajouter && c.touched
  1. À retenir

    • [(x)]="s" = [x]="s()" + (xChange)="s.set($event)": la donnée s'affiche dans le champ, et chaque saisie revient dans la classe.
    • ngModel (avec FormsModule) s'adapte au champ : chaîne, nombre (ou null), booléen, valeur d'un bouton radio, objet avec [ngValue], tableau avec multiple.
    • La forme longue [ngModel] + (ngModelChange) permet de transformer ou de contrôler la saisie.
    • ngModelOptions règle le moment de la mise à jour (change, blur, submit) ; dans un <form>, chaque champ a un name.
    • #c="ngModel" et #f="ngForm" donnent l'état et la validité ; les règles s'écrivent en attributs (required, minlength, email, pattern…).
    • Vos composants acceptent [( )] grâce à model(). Avec des objets et des tableaux, on ne modifie jamais sur place : on crée une nouvelle valeur avec update().
Le two-way binding n'a rien de magique : une liaison de propriété dans un sens, une liaison d'événement dans l'autre. Comprendre ces deux moitiés, c'est savoir les séparer quand la saisie doit être contrôlée.