~6 min de lecture
model() : supprime 60% du boilerplate de tes two-way bindings Angular
Tu veux écrire un composant réutilisable avec une vraie liaison bidirectionnelle. Un <app-rating [(value)]="note" />, un toggle, un stepper, un date picker maison. Le parent passe une valeur, l'enfant la modifie, le parent récupère le changement. Classique.
Et pour ça, tu sors le duo qu'on utilise depuis le passage aux signals : un input() pour recevoir la valeur, un output() suffixé Change pour notifier le parent, et un emit() planqué quelque part. Deux déclarations pour une seule donnée logique, une convention de nommage à respecter au caractère près, et une source de désynchronisation à chaque oubli d'emit().
Depuis Angular 17.2 (stable et recommandé en v20+), tout ça tient en une ligne : model(). Et ce n'est pas juste du sucre syntaxique. C'est un signal writable, partagé entre parent et enfant, qui supprime la classe de bugs la plus pénible des composants custom : l'état qui dérive.
TL;DR
| Besoin | Avant (input() + output()) |
Avec model() |
|---|---|---|
Two-way binding [(x)] |
input() x + output() xChange |
x = model() |
| Valeur obligatoire | input.required() + output() manuel |
x = model.required<T>() |
| Notifier le parent | this.xChange.emit(v) |
this.x.set(v) |
model() = input() + output() fusionnés dans un seul signal writable des deux côtés. Tu lis avec x(), tu écris avec x.set() / x.update(), et Angular propage automatiquement vers le parent.
Le problème : le boilerplate qui ment
Prenons un composant de notation par étoiles. Le parent veut binder la note, l'enfant veut la mettre à jour au clic. Voici la version input() / output() — déjà à base de signals, mais qui sépare encore la lecture de l'écriture :
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
@Component({
selector: 'app-rating',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@for (star of stars; track star) {
<button type="button" (click)="select(star)">
{{ star <= value() ? '★' : '☆' }}
</button>
}
`,
})
export class Rating {
protected readonly stars = [1, 2, 3, 4, 5];
readonly value = input(0);
readonly valueChange = output<number>();
protected select(star: number): void {
this.valueChange.emit(star); // on PENSE à émettre... ou pas
}
}
Côté parent :
@Component({
selector: 'app-review-form',
imports: [Rating],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<app-rating [(value)]="note" />
<p>Note sélectionnée : {{ note() }}</p>
`,
})
export class ReviewForm {
protected readonly note = signal(0);
}
Trois problèmes structurels avec cette version :
- La convention de nommage est fragile. Le
[(value)]ne fonctionne QUE parce que l'output s'appelle exactementvalueChange. Tu renommes l'input enratingsans renommer l'output enratingChange? Le banana-in-a-box casse en silence, sans erreur de compilation parlante. - L'
emit()est optionnel pour le compilateur, obligatoire pour la logique. Tu ajoutes un raccourci clavier qui change la note sans appeleremit(), et le parent ne le voit jamais. État divergent garanti. - Deux symboles pour une seule donnée.
value(la lecture) etvalueChange(l'écriture) décrivent la même information logique, mais rien dans le type ne les relie : à toi de les tenir synchronisés à la main. Et commeinput()est en lecture seule, l'enfant ne peut refléter un changement qu'après l'aller-retour par le parent.
La solution : model()
Même composant, réécrit avec model() :
import { ChangeDetectionStrategy, Component, model } from '@angular/core';
@Component({
selector: 'app-rating',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@for (star of stars; track star) {
<button type="button" (click)="value.set(star)">
{{ star <= value() ? '★' : '☆' }}
</button>
}
`,
})
export class Rating {
protected readonly stars = [1, 2, 3, 4, 5];
readonly value = model(0);
}
Les deux déclarations et l'emit() se résument à une ligne. Et surtout : il n'y a plus d'emit() à oublier. Quand l'enfant fait value.set(star), Angular propage la nouvelle valeur au parent automatiquement, parce que model() génère sous le capot l'input value ET l'output valueChange synchronisés sur le même signal.
Le parent n'a pas changé d'une virgule : [(value)]="note" fonctionne à l'identique. La différence est entièrement interne, mais elle élimine la classe de bugs « j'ai oublié d'émettre ».
Lecture, écriture, mise à jour
model() est un WritableSignal. Tu as donc toute l'API signal :
readonly value = model(0);
increment(): void {
this.value.update(v => Math.min(v + 1, 5)); // propagé au parent
}
reset(): void {
this.value.set(0); // propagé au parent
}
readonly label = computed(() => `${this.value()} / 5`); // dérivation locale
Before / After
// AVANT : 2 déclarations + une convention de nommage + un emit manuel
readonly value = input(0);
readonly valueChange = output<number>();
select(v: number) {
this.valueChange.emit(v);
}
// APRÈS : 1 déclaration, propagation automatique, zéro emit
readonly value = model(0);
select(v: number) {
this.value.set(v);
}
Sur un composant à trois props bidirectionnelles (typique d'un date range picker), tu passes de 6 déclarations + 3 emit() (9 éléments à maintenir) à 3 lignes, et tu supprimes l'intégralité des emit() manuels. D'où le « 60% » du titre, qui n'est pas un chiffre marketing : c'est littéralement le ratio de lignes supprimées sur ce genre de composant.
model.required() : la valeur obligatoire
Si binder une valeur initiale n'a pas de sens (un composant qui DOIT recevoir sa donnée du parent), utilise la forme required :
import { ChangeDetectionStrategy, Component, model } from '@angular/core';
@Component({
selector: 'app-toggle',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<button type="button" role="switch" [attr.aria-checked]="checked()"
(click)="checked.set(!checked())">
{{ checked() ? 'Activé' : 'Désactivé' }}
</button>
`,
})
export class Toggle {
readonly checked = model.required<boolean>();
}
Si le parent oublie le binding [(checked)], tu obtiens une erreur explicite au runtime (NG0950) plutôt qu'un undefined qui se balade. Note qu'avec model.required(), tu ne fournis pas de valeur par défaut : c'est le contrat même de la forme required.
Alias
Comme input() et output(), tu peux découpler le nom interne du nom public via alias :
readonly internalValue = model<string>('', { alias: 'value' });
// le parent écrit [(value)], la classe manipule this.internalValue
Les pièges qui vont te coûter une heure
1. model() n'est PAS la réponse par défaut
C'est le piège le plus courant après la découverte de l'API : tout passer en model(). Non.
- Tu lis seulement une valeur venant du parent ? →
input(). Unmodel()ici expose un setter inutile et suggère faussement aux consommateurs qu'ils peuvent faire un two-way. - Tu émets seulement un événement (un clic, une soumission) ? →
output(). - Tu as vraiment besoin que le parent ET l'enfant modifient la même valeur ? →
model().
Règle simple : model() se justifie uniquement quand le [(banana)] a un sens métier. Pour tout le reste, input() / output() sont plus explicites sur l'intention.
2. Écrire dans un model() depuis un effect() = boucle potentielle
model() est writable, donc tentant à manipuler dans un effect(). Mais si cet effect lit aussi le model, tu crées une boucle de réécriture :
// ANTI-PATTERN
readonly value = model(0);
constructor() {
effect(() => {
if (this.value() > 5) {
this.value.set(5); // réécriture qui peut re-déclencher le cycle
}
});
}
Pour borner ou dériver une valeur de model, préfère linkedSignal() (Angular 19+) ou une simple validation dans le setter d'événement. L'effect() qui réécrit son propre signal est un drapeau rouge, model ou pas.
3. model() ne se binde pas avec [ngModel]
Le nom prête à confusion, mais model() n'a aucun rapport avec ngModel ni avec ControlValueAccessor. Pour intégrer un composant custom dans un formulaire réactif (formControlName), il te faut toujours implémenter ControlValueAccessor. model() gère la liaison [(x)] entre composants, pas l'intégration au système de forms.
4. La mutation directe ne propage rien
model() est un signal : tu dois passer par set() / update(). Muter le contenu d'un objet en place ne déclenche aucune propagation ni détection de changement.
readonly filters = model<{ tags: string[] }>({ tags: [] });
addTag(tag: string): void {
this.filters().tags.push(tag); // ❌ rien ne se propage
this.filters.update(f => ({ ...f, tags: [...f.tags, tag] })); // ✅ nouvelle référence
}
5. Binding one-way sur un model() : c'est permis, et c'est utile
Tu n'es pas obligé d'utiliser la syntaxe banana. Un parent peut binder un model() en lecture seule :
<!-- one-way : le parent pousse, ignore les changements de l'enfant -->
<app-rating [value]="note()" />
<!-- two-way : synchronisation complète -->
<app-rating [(value)]="note" />
C'est pratique pour les cas où certains parents veulent contrôler la valeur sans écouter les retours.
Récap actionnable
- Un
[(x)]à exposer ? Remplaceinput() x+output() xChange+ leemit()parreadonly x = model(). Une ligne, zéroemit(). - Lis avec
x(), écris avecx.set()/x.update(). La propagation vers le parent est automatique. - Valeur obligatoire →
model.required<T>()(pas de valeur par défaut, erreurNG0950si non binté). - Ne mets pas tout en
model():input()pour la lecture seule,output()pour les événements purs.model()se réserve au vrai bidirectionnel. - Jamais d'
effect()qui réécrit son propremodel(): utiliselinkedSignal()pour dériver/borner. model()≠ngModel: pour les formulaires réactifs, c'est toujoursControlValueAccessor.- Toujours
set()/update()avec une nouvelle référence, jamais de mutation en place.
Le gain n'est pas que cosmétique. En supprimant l'emit() manuel, tu supprimes par construction le bug « l'enfant a changé mais le parent ne le sait pas ». C'est moins de code ET moins de surface de bug. Le combo rare.