~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 depuis Angular 19), 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 un couplage à tenir à la main. Le
[(value)]ne fonctionne QUE parce que l'output s'appelle exactementvalueChange. Tu renommes l'input enratingsans renommer l'output ? Depuis Angular 22,strictTemplatesest actif par défaut et le compilateur t'arrête surNG8007: The property and event halves of the two-way binding 'rating' are not bound to the same target.et te suggère mêmeratingChange. Sur un projet qui a explicitement désactivéstrictTemplates, en revanche, le banana-in-a-box casse en silence : Angular émet quand même un écouteurratingChangesur<app-rating>, et faute d'output de ce nom, il retombe sur un écouteur d'événement DOM que rien n'émettra jamais. - 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 déclarations, et tu supprimes l'intégralité des emit() manuels. D'où le « 60% » du titre, qui n'est pas un chiffre marketing : c'est l'ordre de grandeur du ratio d'éléments supprimés 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)] dans un template compilé, tu ne vas même pas jusqu'au runtime : le build échoue sur NG8008: Required input 'checked' from component Toggle must be specified. L'erreur runtime NG0952 (Model is required but no value is available yet.) est le filet pour tout ce que le compilateur ne voit pas : un test qui fait TestBed.createComponent(Toggle) sans setInput, une création dynamique, ou une lecture pendant la construction du composant. C'est la même mécanique que NG0950 pour les inputs. 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 ; binding manquant =NG8008au build dans un template compilé,NG0952au runtime ailleurs : tests, création dynamique.NG0952tombe aussi, template compilé ou pas, si tu lis le model pendant la construction). - 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.