📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

~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 :

  1. La convention de nommage est fragile. Le [(value)] ne fonctionne QUE parce que l'output s'appelle exactement valueChange. Tu renommes l'input en rating sans renommer l'output en ratingChange ? Le banana-in-a-box casse en silence, sans erreur de compilation parlante.
  2. L'emit() est optionnel pour le compilateur, obligatoire pour la logique. Tu ajoutes un raccourci clavier qui change la note sans appeler emit(), et le parent ne le voit jamais. État divergent garanti.
  3. Deux symboles pour une seule donnée. value (la lecture) et valueChange (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 comme input() 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(). Un model() 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 ? Remplace input() x + output() xChange + le emit() par readonly x = model(). Une ligne, zéro emit().
  • Lis avec x(), écris avec x.set() / x.update(). La propagation vers le parent est automatique.
  • Valeur obligatoiremodel.required<T>() (pas de valeur par défaut, erreur NG0950 si 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 propre model() : utilise linkedSignal() pour dériver/borner.
  • model()ngModel : pour les formulaires réactifs, c'est toujours ControlValueAccessor.
  • 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.

📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

AngularKit

Suite d'outils pour développeurs Angular francophones. Apprends, modernise tes réflexes, audite ta codebase.

Produits

Contact

Légal

© 2026 AngularKit. Tous droits réservés.