~7 min de lecture
Ton @HostBinding avec un signal ne fait rien : le silent bug que host: {} corrige
Tu migres un composant vers les signals. Tu remplaces une propriété isActive: boolean par un signal(false). Tu gardes le @HostBinding('class.active') au-dessus, parce que "ça marchait avant". Tu ouvres l'app.
La classe active est appliquée sur l'élément host dès le mount. Elle y reste. Tu appelles set(false), tu vois dans le devtool que le signal contient bien false, la classe est toujours là. Comme si le binding avait été gelé au premier render.
Pas d'erreur, pas de warning. Juste un composant qui ment sur son état. C'est le genre de bug qui passe la CI, passe la review, atterrit en QA six semaines plus tard le jour où quelqu'un veut désactiver la classe. Et l'origine tient dans un détail que le style guide v20 corrige explicitement : @HostBinding et les signals ne sont pas compatibles sans un getter intermédiaire, alors que la clé host: {} supporte les appels de signal comme n'importe quelle expression template.
Valide Angular 17+ (introduction des Signals en stable). La bascule vers
host: {}est renforcée par le style guide Angular v20+.
TL;DR
| Forme | Signal réactif ? | Recommandé v20+ ? |
|---|---|---|
@HostBinding('class.x') x = signal(false) |
Non (silent bug) | Non |
@HostBinding('class.x') get x() { return this.state(); } |
Oui | Non (bruit) |
host: { '[class.x]': 'state()' } |
Oui | Oui |
Règle : une expression host est parsée comme une expression template. Un signal doit être appelé avec (), une méthode avec (), $event est disponible dans les events. Comme dans un {{ }}, comme dans un (click)="...".
Le silent bug en 6 lignes
Regarde bien. Rien ne saute aux yeux :
import { Component, HostBinding, signal } from '@angular/core';
@Component({
selector: 'app-toggle',
template: `<button (click)="active.set(!active())">Toggle</button>`,
})
export class Toggle {
@HostBinding('class.active') active = signal(false);
}
Attendu : la classe active apparaît quand active() vaut true, disparaît quand il vaut false.
Réel : la classe active est appliquée dès le mount et n'en bouge jamais.
Pourquoi ? @HostBinding('class.active') active = signal(false) demande à Angular de lire la propriété active de l'instance à chaque cycle de détection. Cette propriété est la fonction signal elle-même, pas la valeur qu'elle contient. Comme toute fonction JavaScript, elle est truthy. Angular projette donc la classe en permanence. Le signal n'est jamais appelé, il n'y a aucun tracking, et ton set() modifie la valeur interne sans que le binding en soit informé.
Pire : ton test unitaire qui vérifie "la classe est appliquée quand le composant est monté" passe (par accident). Ton test qui vérifie "la classe disparaît" est généralement absent parce qu'on teste rarement le négatif d'un binding host. Le bug survit à la CI.
Le workaround qui marche à moitié
La réaction la plus courante quand on repère le problème :
@Component({
selector: 'app-toggle',
template: `<button (click)="toggle()">Toggle</button>`,
})
export class Toggle {
protected readonly active = signal(false);
@HostBinding('class.active') get activeClass(): boolean {
return this.active();
}
protected toggle(): void {
this.active.update(v => !v);
}
}
Le getter, lui, appelle bien le signal. Angular le lit à chaque détection de changement, obtient un booléen, applique ou retire la classe. Comportement correct.
Mais tu viens de payer un prix :
- Deux membres pour un seul binding : le champ
activeet le getteractiveClass. - Un nom (
activeClass) qui n'a plus de sens métier, juste un rôle de plomberie. - Un getter par binding host - trois classes réactives, trois
attr.*, trois handlers ? Tu ajoutes six getters ou trois handlers à ta classe, sans compter les décorateurs. - Un reviewer doit descendre dans la classe pour comprendre ce que fait le composant sur son host.
Le style guide v20 ne se contente plus du workaround. Il te pousse à passer par la méta-clé host: {}.
La forme canonique : host: {} avec appels de signal
host est une clé de @Component qui existe depuis Angular 2. En 2026 elle est redevenue centrale parce qu'elle est déclarative, traçable, et parce que ses valeurs sont parsées comme des expressions template - donc compatibles nativement avec les signals :
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
@Component({
selector: 'app-toggle',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'[class.active]': 'active()',
'[attr.aria-pressed]': 'active()',
'(click)': 'toggle()',
},
template: `<span>{{ active() ? 'On' : 'Off' }}</span>`,
})
export class Toggle {
protected readonly active = signal(false);
protected toggle(): void {
this.active.update(v => !v);
}
}
Trois points à intérioriser :
'active()'est une expression template, pas une référence de propriété. Angular la parse comme celle d'un{{ }}. L'appel()est réel, le signal est lu, le tracking fonctionne, l'invalidation propage au binding host quandset()ouupdate()est appelé.- Toute la surface host tient dans l'en-tête du composant. Un reviewer voit d'un coup la classe, l'attribut aria et l'event handler.
- Le nombre de membres de la classe reste minimal : un signal, une méthode. Pas de getter tampon, pas de propriété adapter.
Ça marche pareil pour un computed(), une méthode, un ternaire ou une expression composée :
host: {
'[class.dark]': 'theme() === "dark"',
'[style.--accent]': 'accent()',
'[attr.data-severity]': 'severity()',
'[class.disabled]': 'disabled() || loading()',
}
Piège 1 - Oublier le () sur le signal
Le réflexe automatique quand on migre : recopier le nom du champ sans les parenthèses.
host: { '[class.active]': 'active' } // ❌
Ici, active est évalué comme une expression template. Angular résout la propriété active sur le composant, trouve la fonction signal, la considère comme une valeur truthy. La classe est appliquée en permanence. Même silent bug que @HostBinding, autre syntaxe.
Règle : dans host: {}, un signal doit toujours être appelé avec (). Comme dans un {{ signal() }} de template.
Piège 2 - Le style guide v20 pousse à supprimer @HostBinding / @HostListener
Depuis Angular 20, la recommandation officielle est de ne plus introduire de nouveaux @HostBinding / @HostListener :
- La clé
host: {}couvre exactement le même périmètre. - Les décorateurs Angular sont en retrait (
@Inputet@Outputremplacés parinput()etoutput(),@ViewChildremplacé parviewChild()). - Deux syntaxes pour un même effet, c'est un coût de compréhension permanent pour l'équipe.
Côté outillage, la règle @angular-eslint/prefer-host-metadata-property (disponible depuis angular-eslint v20.5, l'inverse de l'ancienne no-host-metadata-property retirée en v19) bloque un retour en arrière. Tu la passes à error dans ta config, la CI arrête la première PR qui réintroduit un @HostBinding :
// eslint.config.js
{
rules: {
'@angular-eslint/prefer-host-metadata-property': 'error',
},
}
C'est le moment de le faire : chaque semaine où tu laisses passer, tu accumules de la dette à purger plus tard.
Piège 3 - $event, méthodes et modifiers dans host: {}
On voit passer l'idée que host: {} ne supporte pas les events complexes. Faux. Une expression event est un event handler template complet :
host: {
'(keydown.escape)': 'onEscape()',
'(pointerdown)': 'onPointer($event)',
'(input)': 'onInput($event.target.value)',
'(document:visibilitychange)': 'onVisibility()',
}
Ce qui marche dans un template marche dans host. $event est disponible. Les modifiers de touche (.escape, .arrow-down, .enter) sont supportés. Les targets globales (document:, window:) aussi - même périmètre que l'ancien @HostListener('document:visibilitychange').
Un point qui pique : sur strictTemplates: true, les méthodes appelées dans host doivent être visibles depuis la vue. Une méthode private n'est pas trouvée - passe-la en protected ou public, comme pour un (click) de template.
Piège 4 - Tests d'un binding host après migration
@HostBinding était testé souvent via fixture.nativeElement ou fixture.debugElement. La migration vers host: {} ne change pas la surface de test, mais deux détails piquent :
En zoneless, un set() sur un signal déclenche bien la détection de changement, mais de manière asynchrone (le scheduler bufferise en microtask/RAF). Le DOM du test ne reflète donc pas la mutation de façon synchrone : pense à await fixture.whenStable() après la mutation qui pilote le binding host :
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Toggle } from './toggle';
describe('Toggle host bindings', () => {
let fixture: ComponentFixture<Toggle>;
beforeEach(async () => {
await TestBed.configureTestingModule({ imports: [Toggle] }).compileComponents();
fixture = TestBed.createComponent(Toggle);
fixture.autoDetectChanges();
await fixture.whenStable();
});
it('reflète l\'état active sur la classe et aria-pressed', async () => {
const host = fixture.nativeElement as HTMLElement;
expect(host.classList.contains('active')).toBe(false);
expect(host.getAttribute('aria-pressed')).toBe('false');
(fixture.componentInstance as unknown as { toggle: () => void }).toggle();
await fixture.whenStable();
expect(host.classList.contains('active')).toBe(true);
expect(host.getAttribute('aria-pressed')).toBe('true');
});
});
Deux détails :
- Assertion sur l'élément host directement, pas sur un enfant du template.
- La méthode
toggle()étantprotected, on passe par un cast d'accès pour l'appeler depuis le test. Alternative propre : déclencher l'action via l'event public (host.click()dans ce cas).
La conversion en trois étapes
Tu ouvres un composant qui traîne encore des @HostBinding / @HostListener, souvent les deux :
Étape 1 - Regrouper. Chaque décorateur @HostBinding('cible') get x() { return expr; } devient une entrée 'cible': 'expr' dans host. Chaque @HostListener('event', ['$event']) onEvent(e) {...} devient '(event)': 'onEvent($event)'. La méthode onEvent reste en place.
Étape 2 - Supprimer les getters d'adaptation. Les getters qui n'existaient que pour rendre le binding réactif (le pattern get isActive() { return this.state(); }) disparaissent. L'expression host appelle directement state().
Étape 3 - Bloquer la régression. Règle ESLint @angular-eslint/prefer-host-metadata-property à error. La CI arrête la prochaine PR qui réintroduit un décorateur.
Sur un composant simple, la migration prend quelques minutes. Sur ta librairie de design, prévois une passe : git grep -l '@HostBinding\|@HostListener' src/ donne la cible, et chaque composant est indépendant.
Ce que tu gagnes
- Fin du silent bug :
host: { '[class.x]': 'x()' }refuse d'être mal écrit sans que ça se voie - unxsans parenthèses casse le binding immédiatement, visible au premier test manuel. - Un endroit à lire pour comprendre ce que le composant fait à son host. Dans le décorateur
@Component, à côté du selector. - Signals réactifs partout sur l'élément host sans getter fantôme.
- Cohérence avec le reste de l'écosystème moderne :
input(),output(),viewChild(),host: {}. Un seul style, celui du v20+.
Le style guide n'a pas retiré @HostBinding par sadisme. Il a retiré un piège qui coûtait cher dès qu'on mélangeait signals et bindings host. Migre maintenant, avant d'avoir 40 composants à corriger le jour où tu comprends pourquoi ton toggle ne se ferme plus.