À 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().
-
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 utiliserngModeldans 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
- Comprendre le fonctionnement de la liaison bidirectionnelle
-
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). ngModelest une directive du moduleFormsModule(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](https://apcpedagogie.com/wp-content/uploads/2026/10/angular-two-way-ngmodel-principe.png)
- Pour suivre ce chapitre, continuez dans le projet
premiers-pas-angularet créez un dossiersrc/app/twoway/. Tous les exemples ont été testés avec Angular 22. - Rappel : sans
FormsModuledans lesimportsdu composant, la compilation échoue :NG8002: Can't bind to 'ngModel' since it isn't a known property of 'input'.
-
ngModelsur tous les types de champsngModels'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.

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"ettype="range",ngModelfournit un vrai nombre. Un champ numérique vide donnenull: déclarez le signalsignal<number | null>(…)si ce cas doit être géré. - Les boutons radio d'un même groupe partagent le même
nameet 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 devilles[1], même s'il a le même contenu (c'est souvent le cas d'une donnée reçue d'un serveur). SanscompareWith, Angular compare les objets par référence : aucune option n'est sélectionnée (selectedIndexvaut-1). La fonctionmemeVillecompare les codes.
-
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$eventet décide quoi en faire.
- Quand la saisie doit être transformée, limitée ou déclencher un traitement, on sépare les deux moitiés :
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 pourtype="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
30affiché dans le champ alors que le signal vaut toujours 10. Pour une limite stricte, préférez les attributsmin/maxet la validation (voir plus bas).
-
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 champngModelest enregistré dans le formulaire sous sonname, qui devient obligatoire. Un champ qui ne doit pas faire partie du formulaire reçoit{ standalone: true }.
- Par défaut, la donnée est mise à jour à chaque frappe.

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
FormsModuleest importé, Angular prend en charge chaque balise<form>:(ngSubmit)remplace(submit)et la page n'est pas rechargée. - Attention : un
ngModelsansnamedans 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.
-
L'état et la validation d'un champ
- Chaque champ
ngModelsuit 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, etmin/maxpour les nombres. - Angular ajoute aussi des classes CSS à chaque champ (
ng-valid,ng-invalid,ng-touched,ng-dirty…) : on peut les styler directement.
- Chaque champ

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 lesname).novalidatedé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.errorspeut valoirnullet ses clés se lisent entre crochets :champNom.errors?.['minlength'].requiredLength. ÉcrirechampNom.errors.minlengthdonneTS2531: Object is possibly 'null'.etTS4111: Property 'minlength' comes from an index signature, so it must be accessed with ['minlength'].
-
[( )]sur vos propres composants :model()- La liaison bidirectionnelle n'est pas réservée à
ngModel. Un composant qui déclare une entrée avecmodel()accepte[(nom)]: le parent fournit la valeur, l'enfant peut la modifier avecset()ouupdate(), et Angular émet automatiquement l'événementnomChange.
- La liaison bidirectionnelle n'est pas réservée à

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 avecinput()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'unngForm), il doit implémenter l'interfaceControlValueAccessor: ce sujet sera traité dans le chapitre consacré aux formulaires.
-
Le piège des objets et des tableaux
- Un signal ne prévient Angular que lorsqu'on lui donne une nouvelle valeur avec
set()ouupdate(). Or[(ngModel)]="profil().prenom"modifie la propriété de l'objet sur place: le signal ne change pas.
- Un signal ne prévient Angular que lorsqu'on lui donne une nouvelle valeur avec

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 uncomputedne fonctionne pas non plus : il n'est pas modifiable. Le champ affiche alors[Computed (total): 3], sans erreur de compilation. Utilisez la forme longue.
-
Aide-mémoire

ngModelconvient 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 ».
-
Activité 1 : un convertisseur de températures
- Générer le composant
act-twoway/convertisseuravec un seul signalcelsius(20 au départ) et trois champs numériques modifiables: Celsius, Fahrenheit et Kelvin.- Fahrenheit et Kelvin sont des
computedarrondis à 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é à
celsiusavec[(ngModel)]; - un bandeau affiche « ❄ Il gèle » (bleu, ≤ 0), « 🌤 Température agréable » (vert, < 30) ou « 🔥 Il fait chaud » (rouge), avec
[class].
- Fahrenheit et Kelvin sont des
- Générer le composant

- 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.
-
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).
- nom, poste et e-mail (champs texte) ; couleur principale (
- Générer le composant

- 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() }}.
-
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 avecmodel.required<number>(), et deux entréesmin(0) etmax(99) avecnumberAttribute. Les boutons se désactivent aux limites et portent unaria-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".
- un compteur par ligne (maximum 5), en forme longue :
- Générer le composant

- 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 ?
-
Activité 4 : un formulaire de contact validé
- Générer le composant
act-twoway/contact: un<form #f="ngForm" novalidate>dont chaque champ a unnameet 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-invalidetng-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.
- nom : obligatoire, au moins 3 caractères ; e-mail : obligatoire et valide ; téléphone : facultatif, mais 8 chiffres s'il est rempli (
- Générer le composant

- Pourquoi le message de confirmation est-il stocké dans un signal, alors que les valeurs saisies sont relues dans
this.nom(),this.sujet()? Que vautf.valueau moment de l'envoi ? - Remplacer
f.resetForm(…)parthis.nom.set(''), etc. : les champs se vident-ils ? Les messages d'erreur réapparaissent-ils ? Pourquoi ?
-
Activité 5 : une liste de tâches modifiable
- Générer le composant
act-twoway/tachesqui 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()"(uncomputed) et(ngModelChange)="toutCocher($event)"; - trois boutons radio Toutes / Actives / Terminées liés à un signal
filtrepar[(ngModel)], et le compteur « N tâche(s) restante(s) sur 3 ».
- une case à cocher par tâche, en forme longue, qui appelle
- Générer le composant

- 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".
-
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 |
-
À 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(avecFormsModule) s'adapte au champ : chaîne, nombre (ounull), booléen, valeur d'un bouton radio, objet avec[ngValue], tableau avecmultiple.- La forme longue
[ngModel]+(ngModelChange)permet de transformer ou de contrôler la saisie. ngModelOptionsrègle le moment de la mise à jour (change,blur,submit) ; dans un<form>, chaque champ a unname.#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 avecupdate().
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.