📧 Reste informé(e) !

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

S'inscrire gratuitement

~5 min de lecture

hostDirectives : partage des comportements entre composants sans copier-coller une seule ligne

Tu as un Button avec un état disabled. Tu as un Card avec le même état disabled. Et un Chip. Et un Input.

À chaque fois, tu copies la même logique : un input(), des liaisons de classe dans host, un handler de clic pour bloquer les interactions, un attribut ARIA. La première fois tu l'écris avec soin. La deuxième tu la colles. La cinquième tu commences à te demander s'il n'y a pas mieux.

Il y a mieux. Angular 15 a introduit la Directive Composition API via hostDirectives. C'est le pattern qui résout ce problème proprement, et la majorité des équipes Angular ne l'utilise toujours pas.


Le problème en vrai

Voici le code que tu as écrit, probablement plusieurs fois :

// button.ts
import { ChangeDetectionStrategy, Component, input } from '@angular/core';

@Component({
  selector: 'app-button',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<button><ng-content /></button>`,
  host: {
    '[class.opacity-50]': 'disabled()',
    '[attr.aria-disabled]': 'disabled() || null',
    '[class.cursor-not-allowed]': 'disabled()',
    '(click)': 'onHostClick($event)',
  },
})
export class Button {
  disabled = input(false);

  onHostClick(event: Event) {
    if (this.disabled()) event.stopImmediatePropagation();
  }
}
// card.ts (copié-collé, find/replace sur le nom de la classe)
import { ChangeDetectionStrategy, Component, input } from '@angular/core';

@Component({
  selector: 'app-card',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<div class="card"><ng-content /></div>`,
  host: {
    '[class.opacity-50]': 'disabled()',
    '[attr.aria-disabled]': 'disabled() || null',
    '[class.cursor-not-allowed]': 'disabled()',
    '(click)': 'onHostClick($event)',
  },
})
export class Card {
  disabled = input(false);

  onHostClick(event: Event) {
    if (this.disabled()) event.stopImmediatePropagation();
  }
}

Cinq composants plus tard, tu as cinq bugs potentiels identiques. Tu corriges cursor-not-allowed dans un endroit, tu oublies les quatre autres. Tu ajoutes pointer-events-none dans Button, tu l'oublies dans Card. Et les tests ? Tu les as écrits cinq fois aussi.

L'instinct OOP pousse vers une classe de base :

// Idée tentante, mauvaise en pratique
abstract class DisabledBase {
  disabled = input(false);
}

@Component({ ... })
export class Button extends DisabledBase { ... }

Ça ne marche pas bien avec Angular. Les input() dans une classe de base créent des frictions avec le système de détection de changements. Et tu ne peux hériter que d'une seule classe : si ton composant a besoin de trois comportements partagés, tu es bloqué.


La solution : hostDirectives

La Directive Composition API te permet d'attacher des directives directement sur l'élément host d'un composant, au niveau du décorateur @Component. Le composant se comporte comme si la directive était appliquée sur lui, sans que le template parent ait à le faire, sans inheritance.

Étape 1 : extraire le comportement dans une directive standalone

// disabled.ts
import { Directive, input } from '@angular/core';

@Directive({
  selector: '[appDisabled]',
  host: {
    '[class.opacity-50]': 'appDisabled()',
    '[attr.aria-disabled]': 'appDisabled() || null',
    '[class.cursor-not-allowed]': 'appDisabled()',
    '(click)': 'onHostClick($event)',
  },
})
export class Disabled {
  appDisabled = input(false);

  onHostClick(event: Event) {
    if (this.appDisabled()) event.stopImmediatePropagation();
  }
}

Étape 2 : composer avec hostDirectives

// button.ts
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { Disabled } from './disabled';

@Component({
  selector: 'app-button',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<button><ng-content /></button>`,
  hostDirectives: [
    {
      directive: Disabled,
      inputs: ['appDisabled: disabled'],
    },
  ],
})
export class Button {}
// card.ts
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { Disabled } from './disabled';

@Component({
  selector: 'app-card',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<div class="card"><ng-content /></div>`,
  hostDirectives: [
    {
      directive: Disabled,
      inputs: ['appDisabled: disabled'],
    },
  ],
})
export class Card {}

Utilisation dans le template parent :

<app-button [disabled]="isSubmitting()">Valider</app-button>
<app-card [disabled]="!hasPermission()">Contenu protégé</app-card>

La directive Disabled est appliquée sur l'élément host de chaque composant. Les classes CSS, les attributs ARIA, le blocage des clics, tout ça vient de la directive, sans aucun code dans le composant. Et l'input appDisabled est aliasé vers disabled dans l'API publique du composant.


Les règles du jeu pour les inputs et outputs

Par défaut, aucun input ni output d'une hostDirective n'est exposé à l'extérieur du composant. C'est du opt-in explicite, ce qui évite la pollution de l'API publique.

hostDirectives: [
  {
    directive: Tooltip,
    inputs: ['tooltipText: hint'],         // "hint" dans le template parent
    outputs: ['tooltipShown: hintShown'],  // "hintShown" dans le template parent
  },
],

La syntaxe 'nomDansLaDirective: nomExposé' te donne le contrôle total sur le nommage. Tu peux exposer appDisabled sous le nom disabled, tooltipContent sous tooltip, etc. Le parent ne voit que ce que tu décides d'exposer, le reste est un détail d'implémentation.

Si tu veux exposer un input sans le renommer :

inputs: ['appDisabled'], // expose "appDisabled" tel quel

Composer plusieurs directives sur un même composant

Rien ne t'oblige à n'en mettre qu'une. Un bouton de soumission peut avoir un comportement disabled et un comportement loading :

// loading.ts
import { Directive, input } from '@angular/core';

@Directive({
  selector: '[appLoading]',
  host: {
    '[class.animate-pulse]': 'appLoading()',
    '[attr.aria-busy]': 'appLoading() ? "true" : null',
  },
})
export class Loading {
  appLoading = input(false);
}
// submit-button.ts
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { Disabled } from './disabled';
import { Loading } from './loading';

@Component({
  selector: 'app-submit-button',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <button type="submit">
      @if (loading.appLoading()) {
        <span class="spinner" aria-hidden="true" />
      }
      <ng-content />
    </button>
  `,
  hostDirectives: [
    { directive: Disabled, inputs: ['appDisabled: disabled'] },
    { directive: Loading, inputs: ['appLoading: loading'] },
  ],
})
export class SubmitButton {
  protected loading = inject(Loading);
}

Le inject(Loading) depuis le composant te donne accès à l'état de la directive dans le template, ici pour afficher un spinner conditionnel. Tu peux injecter toute hostDirective depuis le composant qui la compose, comme n'importe quel autre token DI.


Composer des directives entre elles

Les hostDirectives ne sont pas réservées aux composants. Tu peux créer une directive d'ordre supérieur qui regroupe plusieurs comportements :

// interactive.ts
import { Directive } from '@angular/core';
import { Ripple } from './ripple';
import { FocusVisible } from './focus-visible';
import { Disabled } from './disabled';

@Directive({
  selector: '[appInteractive]',
  hostDirectives: [
    { directive: Ripple },
    { directive: FocusVisible },
    { directive: Disabled, inputs: ['appDisabled: disabled'] },
  ],
})
export class Interactive {}

Maintenant tu appliques appInteractive sur n'importe quel composant UI pour lui donner ripple + focus visible + disabled en une seule déclaration. Et si demain tu veux ajouter un comportement de pressed state pour l'accessibilité, tu le fais dans Interactive et tous tes composants en bénéficient immédiatement.


Avant / Après

Avant : comportement dupliqué dans 5 composants :

// button.ts, card.ts, chip.ts, input.ts, badge.ts : tous identiques sur cette partie
disabled = input(false);

host: {
  '[class.opacity-50]': 'disabled()',
  '[attr.aria-disabled]': 'disabled() || null',
  '[class.cursor-not-allowed]': 'disabled()',
  '(click)': 'onHostClick($event)',
},

onHostClick(event: Event) {
  if (this.disabled()) event.stopImmediatePropagation();
}

Après : une directive, cinq consommateurs :

hostDirectives: [
  { directive: Disabled, inputs: ['appDisabled: disabled'] },
]

Un seul endroit à maintenir. Un seul fichier de test pour Disabled. Un seul bug à corriger. Quand tu améliores l'accessibilité (aria-disabled, pointer-events-none en CSS), la correction se propage automatiquement partout.


Ce que hostDirectives ne fait pas

Quelques points pour éviter les déceptions :

Pas de remplacement pour la logique métier. hostDirectives est fait pour les comportements transversaux : styles, accessibilité, interactions DOM, animations. Si ta logique est métier (règles de validation, calculs, appels HTTP), elle reste dans le composant ou le service.

Les outputs doivent être explicitement exposés. Comme les inputs, ils ne bubblent pas automatiquement. Si ta directive expose un output(), tu dois le lister dans outputs pour qu'il soit accessible depuis le parent.

Pas de template dans une directive. Si tu as besoin de projeter du DOM en fonction de l'état d'une hostDirective (un spinner, une icône), tu le fais dans le template du composant en injectant la directive avec inject().

Le selector de la directive est ignoré. Quand tu utilises une directive dans hostDirectives, son selector n'est pas pris en compte : c'est le composant qui la compose qui détermine où elle s'applique.


Récap actionnable

  • Identifie tes duplications : états visuels (loading, disabled, error), accessibilité (aria-busy, focus visible), interactions DOM (ripple, drag handle, keyboard nav)
  • Extrais chaque comportement dans une @Directive avec ses propres input() et bindings dans host: {}
  • Compose dans tes composants via hostDirectives + mapping d'inputs explicite
  • Teste la directive isolément : elle tourne sur n'importe quel élément host, les tests sont simples à écrire
  • Crée des directives d'ordre supérieur qui regroupent plusieurs comportements pour des familles de composants (boutons, champs de formulaire, cartes)

Commence avec une seule duplication dans ta codebase. Transforme-la en directive. Compose-la sur deux composants. En moins d'une heure, tu auras ton premier pattern hostDirectives en production, et tu commenceras à voir les autres endroits où l'appliquer.

📧 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.