📧 Reste informé(e) !

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

S'inscrire gratuitement

~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 active et le getter activeClass.
  • 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 :

  1. '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 quand set() ou update() est appelé.
  2. 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.
  3. 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 (@Input et @Output remplacés par input() et output(), @ViewChild remplacé par viewChild()).
  • 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() étant protected, 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 - un x sans 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.

📧 Reste informé(e) !

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

S'inscrire gratuitement

`outputFromObservable` et `outputToObservable` : les 2 ponts que ta migration `EventEmitter` → `output()` ignore

20 juillet 2026

Tu passes tes `@Output() EventEmitter` en `output()` et d'un coup ton `.pipe(debounceTime())` ne compile plus, ton service RxJS ne se branche plus, et tes tests hurlent. Les deux fonctions d'interop existent depuis Angular 17.3 (stables depuis la 19) mais 90% des migrations les ratent. Tour des 5 pièges qui cassent ton flux RxJS quand tu passes aux outputs signals, avec les patterns pour reprendre la main.

Angular Signals RxJS Composants Migration Bonnes pratiques

Signal `equal` : pourquoi ton `set()` avec un objet ne re-render rien (et les 5 pièges qui te bouffent la journée)

17 juillet 2026

Tu appelles set() avec un nouvel objet, ton composant ne bouge pas. Ou pire : tu appelles set() avec la même valeur, et Angular re-render quand même. La fonction equal des signals est piégeuse dès que tu quittes les primitives. Tour des 5 pièges qui sabotent tes re-renders et les patterns pour reprendre la main sans casser les perfs.

Angular Signals Réactivité Bonnes pratiques Performance

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.