Front-end angularforall.com

- Directives Custom Angular : progresser en expertise

Angular Directive-Custom Directive-Attribut Directive-Structurelle Hostdirectives Host-Listener Host-Binding Templateref Viewcontainerref Signal-Inputs Click-Outside Ng-Validators
Directives Custom Angular : progresser en expertise

Creez vos directives Angular 17+ : Signal inputs, host metadata, hostDirectives, structurelles, ClickOutside, AutoFocus, validators et SSR-safe.

Pourquoi maîtriser les directives custom ?

Les directives sont la colonne vertébrale invisible d'une application Angular avancée. Les composants attirent l'attention car ils produisent du visible ; les directives produisent du comportement. Toute application sérieuse contient en réalité plus de directives que de composants — c'est juste qu'on les voit moins.

Concrètement : ajouter un focus trap dans une modale, fermer un menu quand on clique en dehors, lazy-loader une image au scroll, debouncer un input, gérer une autorisation par rôle, écouter des touches clavier, valider un mot de passe, injecter un attribut ARIA quand un état change… Chacun de ces besoins est l'occasion de créer une directive. Et chaque directive remplace 20 à 50 lignes de code dispersé dans des composants, refactorise les écrans qui l'utilisent, et accélère vos prochains développements.

Angular 17+ a profondément modernisé l'API des directives : Standalone par défaut, Signal inputs en remplacement de @Input(), métadonnée host en remplacement de @HostBinding/@HostListener, et surtout hostDirectives qui transforme la façon dont on compose des comportements transverses sans héritage. Cet article couvre l'intégralité de ces outils modernes, avec dix directives prêtes à copier dans vos projets.

Ce que cet article couvre

  • L'anatomie complète d'une directive Angular 17+ (Standalone, Signals, host metadata).
  • Les deux familles : directive d'attribut et directive structurelle.
  • Le pattern hostDirectives, le mécanisme moderne pour composer plusieurs comportements sans héritage.
  • Une dizaine de directives prêtes à copier : ClickOutside, AutoFocus, LazyLoad, Debounce, HasPermission, PasswordStrength.
  • Comment tester une directive avec TestBed, et comment la rendre compatible SSR et zoneless.
À retenir : une directive bien conçue est l'arme la plus rentable d'une codebase Angular. Elle encapsule un comportement transverse, le rend testable indépendamment, et l'expose comme un attribut HTML lisible (autoFocus, clickOutside, hasPermission).

Trois familles de directives à distinguer

Angular distingue officiellement trois catégories. Les directives d'attribut modifient l'apparence ou le comportement d'un élément existant : ngClass, ngStyle, ou vos propres [appHighlight], [appTooltip]. Les directives structurelles ajoutent, suppriment ou répètent du DOM via TemplateRef et ViewContainerRef : *ngIf, *ngFor, *ngSwitch, et toute directive préfixée par *. Les composants sont techniquement des directives avec un template — ils n'entrent pas dans le périmètre de cet article. La règle pour choisir : si vous voulez ajouter un comportement à un élément existant, c'est une directive d'attribut ; si vous voulez conditionner le rendu d'un fragment de DOM, c'est une directive structurelle ; si vous voulez créer une nouvelle balise avec sa propre structure visuelle, c'est un composant.

Anatomie d'une directive Angular

Une directive est une classe annotée avec @Directive. Le décorateur fournit le sélecteur CSS qui la déclenche, les host bindings, et — depuis Angular 14 — le mode standalone qui dispense de NgModule.

// highlight.directive.ts — version "tout-en-un" pour bien voir les pièces
import { Directive, ElementRef, inject, input } from '@angular/core';

@Directive({
  selector: '[appHighlight]',   // attribut qui déclenche la directive
  standalone: true,            // depuis Angular 14, "true" est la norme
  host: {
    '(mouseenter)': 'onEnter()',  // host listener déclaratif
    '(mouseleave)': 'onLeave()',
    '[style.transition]': '"background 200ms"', // host binding statique
  },
})
export class HighlightDirective {
  // Référence vers l'élément DOM hôte — injection moderne
  private readonly el = inject(ElementRef<HTMLElement>);

  // Couleur configurable depuis l'extérieur
  readonly color = input('#fff3a0', { alias: 'appHighlight' });

  onEnter() { this.el.nativeElement.style.background = this.color(); }
  onLeave() { this.el.nativeElement.style.background = ''; }
}

Les six pièces à connaître

  • selector — sélecteur CSS qui détermine où la directive s'applique : attribut [appHighlight], classe .tooltip, ou même button[primary].
  • standalone: true — la directive n'a plus besoin d'être déclarée dans un NgModule ; elle est importable directement par le composant qui l'utilise.
  • host metadata — déclare les listeners et bindings côté hôte, lisiblement dans le décorateur.
  • inject(ElementRef) — fonction moderne d'injection, supplante le constructeur depuis Angular 14.
  • input() — Signal input qui remplace @Input(). La valeur est lue via this.color() et s'intègre directement à computed() et effect().
  • L'alias{ alias: 'appHighlight' } permet de réutiliser le nom du sélecteur comme propriété ([appHighlight]="couleur" au lieu de [color]="couleur").

Utilisation côté template

// app.component.ts — Standalone, on importe la directive directement
import { Component } from '@angular/core';
import { HighlightDirective } from './highlight.directive';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [HighlightDirective],
  template: `
    <p appHighlight>Survole-moi (jaune par défaut)</p>
    <p [appHighlight]="'#cfe8ff'">Survole-moi (bleu pâle)</p>
  `,
})
export class AppComponent {}

Directive d'attribut moderne (Signal inputs)

La directive d'attribut est la forme la plus courante. Elle reçoit des input(), modifie l'élément hôte, et peut émettre via output(). Voici une version production-ready d'une directive Tooltip pilotée par Signals.

import {
  Directive, ElementRef, inject, input, computed, effect, signal,
} from '@angular/core';

@Directive({
  selector: '[appTooltip]',
  standalone: true,
  host: {
    '(mouseenter)': 'show()',
    '(mouseleave)': 'hide()',
    '(focus)': 'show()',
    '(blur)': 'hide()',
  },
})
export class TooltipDirective {
  private readonly el = inject(ElementRef<HTMLElement>);

  // Texte affiché — input.required(): erreur si non fourni au template
  readonly text = input.required<string>({ alias: 'appTooltip' });

  // Position configurable — type narrow par littéraux
  readonly position = input<'top' | 'bottom'>('top');

  private readonly visible = signal(false);
  private bubble: HTMLDivElement | null = null;

  constructor() {
    // Synchronise affichage et DOM via un effect — pas de manuel
    effect(() => {
      if (this.visible()) this.render();
      else this.dispose();
    });
  }

  show() { this.visible.set(true); }
  hide() { this.visible.set(false); }

  private render() {
    this.bubble = document.createElement('div');
    this.bubble.className = `tooltip-bubble tooltip-${this.position()}`;
    this.bubble.textContent = this.text();
    const rect = this.el.nativeElement.getBoundingClientRect();
    Object.assign(this.bubble.style, {
      position: 'fixed',
      top:  `${rect.top}px`,
      left: `${rect.left}px`,
      zIndex: '9999',
    });
    document.body.appendChild(this.bubble);
  }

  private dispose() {
    this.bubble?.remove();
    this.bubble = null;
  }
}

Cette directive expose une API minimaliste (appTooltip + position) et gère elle-même son cycle de vie complet via un effect(). Elle est testable indépendamment du composant qui l'utilise et réutilisable dans n'importe quel template.

Usage côté template

<button [appTooltip]="'Supprimer cet article'" position="bottom">
  🗑️
</button>

host metadata vs HostListener/HostBinding

Historiquement, Angular utilisait les décorateurs @HostListener et @HostBinding sur les méthodes et propriétés. Depuis Angular 16, l'équipe officielle recommande de remplacer ces décorateurs par l'objet host dans @Directive. Plus lisible, mieux outillé, et compatible avec l'inférence de types du Language Service.

Avant — décorateurs sur les membres

@Directive({ selector: '[appPress]', standalone: true })
export class PressDirective {
  @HostBinding('class.pressed') pressed = false;

  @HostListener('mousedown') down() { this.pressed = true; }
  @HostListener('mouseup')   up()   { this.pressed = false; }
  @HostListener('mouseleave') leave(){ this.pressed = false; }
}

Après — host metadata recommandé

@Directive({
  selector: '[appPress]',
  standalone: true,
  host: {
    '[class.pressed]': 'pressed()',
    '(mousedown)': 'pressed.set(true)',
    '(mouseup)':   'pressed.set(false)',
    '(mouseleave)': 'pressed.set(false)',
  },
})
export class PressDirective {
  readonly pressed = signal(false);
}

Quand garder @HostListener

Le décorateur reste indispensable pour écouter des événements globaux qui ne se produisent pas sur l'élément hôte — typiquement document:click, document:keydown, window:resize, window:scroll. Dans host, on ne peut référencer que les events de l'hôte ; pour le global, @HostListener('document:click', ['$event']) reste la voie.

Dans le futur, l'équipe Angular envisage d'autoriser également les targets globaux dans host, mais à ce jour (Angular 19), ce n'est pas le cas. Pour éviter d'avoir deux styles différents dans la même classe, certaines équipes préfèrent garder @HostListener partout — c'est un compromis acceptable, mais vous perdez les bénéfices d'inférence du Language Service pour les events locaux.

Astuce typage : dans les bindings host, vous pouvez écrire directement une expression Signal — '[class.active]': 'isActive()'. Le Language Service vérifie les types et complète sur les méthodes/propriétés de la classe.

Directives structurelles — TemplateRef et ViewContainerRef

Une directive structurelle ne se contente pas de modifier l'élément hôte — elle ajoute, supprime ou répète des fragments de DOM. Elle reçoit deux services magiques : TemplateRef (le contenu) et ViewContainerRef (le slot où l'instancier).

Exemple — *appIfRole (sécurité par rôle)

import {
  Directive, inject, input, effect,
  TemplateRef, ViewContainerRef,
} from '@angular/core';
import { AuthService } from './auth.service';

@Directive({
  selector: '[appIfRole]',
  standalone: true,
})
export class IfRoleDirective {
  private readonly tpl = inject(TemplateRef<unknown>);
  private readonly vcr = inject(ViewContainerRef);
  private readonly auth = inject(AuthService);

  // Le rôle requis arrive depuis *appIfRole="'admin'" — alias obligatoire
  readonly role = input.required<string>({ alias: 'appIfRole' });

  constructor() {
    effect(() => {
      const allowed = this.auth.userHasRole(this.role());
      this.vcr.clear();
      if (allowed) this.vcr.createEmbeddedView(this.tpl);
    });
  }
}

Usage côté template

<button *appIfRole="'admin'" (click)="delete()">Supprimer</button>

<!-- Le * est sucre syntaxique — l'équivalent strict est : -->
<ng-template [appIfRole]="'admin'">
  <button (click)="delete()">Supprimer</button>
</ng-template>

Bonus — type guard pour l'autocomplete dans le template

Pour qu'Angular Language Service propose l'autocomplétion sur le contexte du template (*ngFor-style), exposez une méthode statique ngTemplateContextGuard.

// Ajout dans la classe IfRoleDirective
static ngTemplateContextGuard<T>(
  dir: IfRoleDirective,
  ctx: unknown,
): ctx is { $implicit: string } { return true; }

hostDirectives — composition sans héritage

Introduit en Angular 15, hostDirectives est le mécanisme moderne pour composer plusieurs comportements sur un même composant ou une même directive. Là où JavaScript pur recourrait à l'héritage multiple (impossible) ou aux mixins, Angular utilise l'injection automatique de directives au moment de l'instanciation.

Cas concret — composant Button avec ripple, analytics et a11y

@Directive({ selector: '[appRipple]', standalone: true })
export class RippleDirective { /* effet ripple Material */ }

@Directive({ selector: '[appTrack]', standalone: true })
export class TrackDirective {
  event = input.required<string>();
  // ... envoie un event analytics au clic
}

@Component({
  selector: 'app-button',
  standalone: true,
  hostDirectives: [
    RippleDirective,
    {
      directive: TrackDirective,
      inputs: ['event:trackEvent'], // mapping pour exposer un input
    },
  ],
  template: `<ng-content></ng-content>`,
})
export class ButtonComponent {}

Côté consommateur, <app-button trackEvent="cta-click">Acheter</app-button> bénéficie automatiquement du ripple visuel ET de l'analytics, sans avoir à se soucier des deux directives. Le composant Button reste minimaliste ; les directives restent isolées et testables.

Avantages clés

  • Aucun héritage — chaque directive reste indépendante, testable et tree-shakable.
  • Composition explicite — la liste des comportements est lisible dans le décorateur, pas perdue dans une hiérarchie de classes.
  • API mappable — vous pouvez renommer les inputs/outputs au passage pour ne pas exposer les détails internes de chaque directive sous-jacente.
  • Performances — Angular optimise l'instanciation conjointe, et les directives partagent le même change detector.
À retenir : hostDirectives remplace définitivement les patterns « base class » et « mixin » d'avant Angular 15. C'est la voie officielle pour les cross-cutting concerns (analytics, theming, a11y, focus management).

Limitations à connaître

Quelques contraintes valent d'être notées avant d'industrialiser le pattern. Une directive référencée par hostDirectives doit être standalone: true — l'API ne supporte pas les anciennes directives modulaires. Vous pouvez exposer ses inputs et outputs au consommateur, mais uniquement via la syntaxe d'objet ({ directive: D, inputs: ['x:y'], outputs: ['a:b'] }) — vous ne pouvez pas exposer une sous-partie sélective. Enfin, deux hostDirectives qui définissent le même sélecteur d'événement sur l'hôte produisent une erreur : Angular ne tente pas de fusionner, il faut renommer ou refactoriser. Ces limites sont volontaires : elles forcent une composition explicite et lisible plutôt qu'une magie implicite difficile à débuguer.

Injection de dépendances dans les directives

Une directive participe pleinement à l'injecteur Angular. Elle peut consommer des services globaux, des services scopés au composant parent, et même exposer ses propres providers pour partager de l'état avec d'autres directives sur le même nœud.

Injection moderne via inject()

import { Directive, inject, ElementRef, Renderer2 } from '@angular/core';
import { Router } from '@angular/router';

@Directive({ selector: '[appExternalLink]', standalone: true })
export class ExternalLinkDirective {
  // inject() fonctionne dans les champs de classe et les fonctions
  // — bien plus testable qu'un constructeur géant
  private readonly el = inject(ElementRef<HTMLAnchorElement>);
  private readonly renderer = inject(Renderer2);
  private readonly router = inject(Router);

  constructor() {
    this.renderer.setAttribute(this.el.nativeElement, 'target', '_blank');
    this.renderer.setAttribute(this.el.nativeElement, 'rel', 'noopener noreferrer');
  }
}

Communication entre directives sur le même hôte

Pattern utile : une directive « container » fournit un service, plusieurs directives « items » sur les éléments enfants l'injectent et s'enregistrent auprès d'elle. C'est exactement le modèle de FormGroupDirective et FormControlName des Reactive Forms.

@Directive({ selector: '[appAccordion]', standalone: true })
export class AccordionDirective {
  private readonly panels = signal<PanelDirective[]>([]);

  register(panel: PanelDirective) {
    this.panels.update(p => [...p, panel]);
  }
}

@Directive({ selector: '[appPanel]', standalone: true })
export class PanelDirective {
  // SkipSelf garantit qu'on remonte chercher le parent et non soi-même
  private readonly accordion = inject(AccordionDirective, { skipSelf: true });

  constructor() {
    this.accordion.register(this);
  }
}

Patterns pratiques : ClickOutside, AutoFocus, LazyLoad

Cinq directives qui devraient figurer dans toute codebase Angular sérieuse, prêtes à copier-coller.

1. ClickOutside — fermer un menu

import { Directive, ElementRef, HostListener, inject, output } from '@angular/core';

@Directive({ selector: '[appClickOutside]', standalone: true })
export class ClickOutsideDirective {
  private readonly el = inject(ElementRef<HTMLElement>);
  readonly clickOutside = output<void>();

  @HostListener('document:click', ['$event.target'])
  onDocumentClick(target: HTMLElement) {
    if (!this.el.nativeElement.contains(target)) {
      this.clickOutside.emit();
    }
  }
}

2. AutoFocus — focus au montage

import { Directive, ElementRef, inject, afterNextRender, input } from '@angular/core';

@Directive({ selector: '[appAutoFocus]', standalone: true })
export class AutoFocusDirective {
  private readonly el = inject(ElementRef<HTMLElement>);
  readonly enabled = input(true, { alias: 'appAutoFocus' });

  constructor() {
    // afterNextRender() est SSR-safe : ne s'exécute que côté navigateur
    afterNextRender(() => {
      if (this.enabled()) this.el.nativeElement.focus();
    });
  }
}

3. LazyLoad — image au scroll

import { Directive, ElementRef, inject, afterNextRender, input } from '@angular/core';

@Directive({ selector: 'img[appLazySrc]', standalone: true })
export class LazyLoadDirective {
  private readonly el = inject(ElementRef<HTMLImageElement>);
  readonly src = input.required<string>({ alias: 'appLazySrc' });

  constructor() {
    afterNextRender(() => {
      const observer = new IntersectionObserver(([entry]) => {
        if (entry.isIntersecting) {
          this.el.nativeElement.src = this.src();
          observer.disconnect();
        }
      }, { rootMargin: '200px' });
      observer.observe(this.el.nativeElement);
    });
  }
}

4. Debounce — input avec délai

import { Directive, ElementRef, inject, input, output, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { fromEvent, debounceTime, map } from 'rxjs';

@Directive({ selector: 'input[appDebounce]', standalone: true })
export class DebounceDirective {
  private readonly el = inject(ElementRef<HTMLInputElement>);
  readonly delay = input(300, { alias: 'appDebounce' });
  readonly debounced = output<string>();

  constructor() {
    fromEvent<Event>(this.el.nativeElement, 'input')
      .pipe(
        map(e => (e.target as HTMLInputElement).value),
        debounceTime(this.delay()),
        takeUntilDestroyed(),
      )
      .subscribe(v => this.debounced.emit(v));
  }
}

5. HasPermission — directive structurelle

import { Directive, TemplateRef, ViewContainerRef, inject, input, effect } from '@angular/core';
import { AuthService } from './auth.service';

@Directive({ selector: '[appHasPermission]', standalone: true })
export class HasPermissionDirective {
  private readonly tpl = inject(TemplateRef<unknown>);
  private readonly vcr = inject(ViewContainerRef);
  private readonly auth = inject(AuthService);

  readonly perms = input.required<string[]>({ alias: 'appHasPermission' });

  constructor() {
    effect(() => {
      const ok = this.auth.hasAll(this.perms());
      this.vcr.clear();
      if (ok) this.vcr.createEmbeddedView(this.tpl);
    });
  }
}

Validation custom de formulaires

Une directive peut s'enregistrer comme validator de Reactive Forms via le token NG_VALIDATORS. Elle s'applique alors automatiquement à tout FormControl bound sur l'élément hôte.

import { Directive } from '@angular/core';
import {
  NG_VALIDATORS, Validator, AbstractControl, ValidationErrors,
} from '@angular/forms';

@Directive({
  selector: '[appPasswordStrength]',
  standalone: true,
  providers: [{
    provide: NG_VALIDATORS,
    useExisting: PasswordStrengthDirective,
    multi: true,
  }],
})
export class PasswordStrengthDirective implements Validator {
  validate(control: AbstractControl): ValidationErrors | null {
    const v = control.value ?? '';
    if (!v) return null;

    const errors: ValidationErrors = {};
    if (v.length < 8)        errors['tooShort']    = true;
    if (!/[A-Z]/.test(v))    errors['noUpper']     = true;
    if (!/[a-z]/.test(v))    errors['noLower']     = true;
    if (!/\d/.test(v))       errors['noDigit']     = true;
    if (!/[^A-Za-z0-9]/.test(v)) errors['noSpecial'] = true;

    return Object.keys(errors).length ? errors : null;
  }
}

Usage

<form [formGroup]="form">
  <input formControlName="password" appPasswordStrength>
  @if (form.controls.password.errors; as e) {
    <ul>
      @if (e['tooShort'])  { <li>Au moins 8 caractères</li> }
      @if (e['noUpper'])   { <li>Une majuscule requise</li> }
      @if (e['noDigit'])   { <li>Un chiffre requis</li> }
      @if (e['noSpecial']) { <li>Un caractère spécial requis</li> }
    </ul>
  }
</form>

Avec ce pattern, votre composant n'a aucune logique de validation — c'est la directive qui s'en charge, et l'erreur structurée par clé permet d'afficher un message ciblé pour chaque critère manquant. La même directive est réutilisable dans toute l'application (formulaire d'inscription, changement de mot de passe, espace admin) sans aucune adaptation.

Async validators et cross-field validation

Pour une validation asynchrone (vérifier qu'un email n'est pas déjà pris en base), créez une directive qui implémente AsyncValidator et provide NG_ASYNC_VALIDATORS. La méthode validate() renvoie alors un Observable<ValidationErrors | null> ou une Promise. Pour la validation transverse à plusieurs champs (mot de passe ≠ confirmation), placez la directive sur le FormGroup parent et accédez aux sous-contrôles via control.get('confirmPassword').

SSR-safe et compatibilité zoneless

Une directive qui touche au DOM doit être prudente dans deux scénarios modernes : le rendu côté serveur (Angular Universal/SSR) et le mode zoneless (Angular 18+). Les règles tiennent en quelques lignes.

Règles SSR-safe

  • N'accédez jamais directement à document, window, localStorage dans le constructeur ou ngOnInit — ils n'existent pas sur le serveur.
  • Pour le code purement navigateur, utilisez afterNextRender(() => { ... }) ou afterRender(). Ces fonctions s'exécutent uniquement côté browser.
  • Si vous devez branche-r par plateforme : inject(PLATFORM_ID) + isPlatformBrowser(platformId).
  • Évitez setTimeout(fn, 0) pour différer un focus — c'est non SSR-safe et bloque l'hydration. Préférez afterNextRender.

Règles zoneless

  • Préférez les signal inputs et la métadonnée host aux décorateurs @Input et @HostBinding — Angular réagit automatiquement aux changements de signal sans Zone.js.
  • Évitez les mutations directes du DOM via nativeElement dans des callbacks de timers ou d'observers — Angular ne saura pas qu'il faut re-rendre. Préférez modifier un Signal qui pilote un host binding.
  • Utilisez takeUntilDestroyed() pour les abonnements RxJS dans les directives — il s'intègre nativement au cycle de vie sans dépendre de Zone.
  • Testez explicitement avec provideExperimentalZonelessChangeDetection() dans main.ts pour valider que vos directives n'utilisent pas Zone implicitement.

Tester une directive et bonnes pratiques

Une directive se teste via un composant host minimaliste qui l'applique. TestBed instancie ce host, et vous vérifiez l'effet de la directive sur le DOM.

import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { HighlightDirective } from './highlight.directive';

@Component({
  standalone: true,
  imports: [HighlightDirective],
  template: `<p [appHighlight]="color">Test</p>`,
})
class HostComponent { color = '#ff0'; }

describe('HighlightDirective', () => {
  it('applique la couleur au mouseenter', () => {
    const fixture = TestBed.createComponent(HostComponent);
    fixture.detectChanges();
    const p = fixture.debugElement.query(By.css('p')).nativeElement as HTMLElement;

    p.dispatchEvent(new MouseEvent('mouseenter'));
    fixture.detectChanges();
    expect(p.style.background).toBe('rgb(255, 255, 0)');

    p.dispatchEvent(new MouseEvent('mouseleave'));
    fixture.detectChanges();
    expect(p.style.background).toBe('');
  });
});

Bonnes pratiques de conception

  • Préfixez vos sélecteurs (app, ui, my) pour éviter les collisions avec les libs tierces et les futures versions d'Angular.
  • Une directive = une responsabilité claire. Si vous avez trois @HostListener sans rapport, c'est sans doute trois directives.
  • Préférez les Signals (input(), signal(), effect()) aux APIs legacy (@Input, ngOnChanges) — meilleur typage, intégration zoneless, lecture plus claire.
  • Composez via hostDirectives plutôt que par héritage.
  • Documentez chaque directive avec un commentaire JSDoc en haut de la classe : sélecteur, inputs, outputs, exemple d'usage.
  • Pour les directives largement réutilisées dans votre design system, exportez-les dans une lib partagée et publiez-les en interne via Nx ou Yarn workspaces.

Conclusion

Les directives custom sont le levier le plus rapide pour transformer une codebase Angular procédurale en architecture déclarative. Là où un développeur junior duplique le code de gestion d'un click outside dans dix composants, un développeur senior écrit une directive ClickOutsideDirective en quinze lignes, la teste une fois, et la réutilise indéfiniment. Le gain n'est pas qu'esthétique : c'est moins de bugs, des reviews plus simples, et des comportements UI testables indépendamment des écrans.

La direction donnée par l'équipe Angular est claire : Standalone par défaut, Signal inputs au lieu de @Input, host metadata au lieu de @HostListener/@HostBinding, et hostDirectives pour toute composition de comportements transverses. Ces outils permettent d'écrire des directives compatibles SSR, prêtes pour le mode zoneless, et lisibles pour un nouveau venu sur le projet. C'est l'investissement à fort ROI qui distingue un développeur Angular intermédiaire d'un développeur capable d'architecturer une librairie UI complète.

Concrètement, après avoir lu cet article, votre prochaine action devrait être d'auditer votre codebase : combien de fois écrivez-vous la même logique de focus, de scroll, de validation ou de gestion d'événements ? Chaque répétition est une directive en gestation. Extraire ces patterns dans une petite lib interne (ou simplement un dossier shared/directives) réduit de 30 à 50 % la quantité de code dans vos composants tout en améliorant leur testabilité. C'est l'un des refactorings les plus rentables sur un projet Angular existant.

Récapitulatif des bonnes pratiques :
  • Standalone + Signal inputs (input(), input.required()) pour toute nouvelle directive
  • Privilégier host: { ... } dans le décorateur aux @HostListener/@HostBinding
  • Garder @HostListener('document:...') pour les events globaux uniquement
  • Utiliser hostDirectives pour composer plusieurs comportements (ripple + analytics + a11y)
  • Pour les structurelles, injecter TemplateRef + ViewContainerRef et piloter via effect()
  • Ajouter ngTemplateContextGuard pour l'autocomplete dans le template
  • Rendre SSR-safe avec afterNextRender() au lieu de setTimeout
  • Utiliser takeUntilDestroyed() pour les abonnements RxJS
  • Préfixer le sélecteur (app, ui) pour éviter les collisions
  • Tester via un composant host minimaliste avec TestBed

Partager