AngularJSDirective Composition

Directive Composition in Angular

Directive composition is one of Angular's most powerful features for building reusable, composable UI behavior. Instead of inheriting from a base class, you can compose multiple directives together on a single host element, layering behaviors cleanly and declaratively.

Angular 15+ introduced the hostDirectives API, making directive composition a first-class feature.

What Is Directive Composition?

Directive composition lets you attach one or more directives to a component or directive from within its decorator, rather than requiring the template user to do it manually. This is the hostDirectives API.

Think of it like mixins — you can bundle common behaviors (hover state, focus ring, disabled state) into small directives and compose them into larger components.

Note
The hostDirectives API was introduced in Angular 15. It only works with standalone directives.
Basic hostDirectives Example

Create two small behavior directives, then compose them into a button component:

TS
// highlight.directive.ts
import { Directive, HostBinding, HostListener } from '@angular/core';

@Directive({
  standalone: true,
  selector: '[appHighlight]',
})
export class HighlightDirective {
  @HostBinding('style.backgroundColor') bg = 'transparent';

  @HostListener('mouseenter') onEnter() {
    this.bg = '#e0f0ff';
  }

  @HostListener('mouseleave') onLeave() {
    this.bg = 'transparent';
  }
}

TS
// disabled-state.directive.ts
import { Directive, HostBinding, Input } from '@angular/core';

@Directive({
  standalone: true,
  selector: '[appDisabledState]',
})
export class DisabledStateDirective {
  @Input() appDisabledState = false;

  @HostBinding('attr.disabled')
  get disabledAttr() {
    return this.appDisabledState ? '' : null;
  }

  @HostBinding('style.opacity')
  get opacity() {
    return this.appDisabledState ? '0.5' : '1';
  }
}

TS
// fancy-button.component.ts
import { Component } from '@angular/core';
import { HighlightDirective } from './highlight.directive';
import { DisabledStateDirective } from './disabled-state.directive';

@Component({
  standalone: true,
  selector: 'app-fancy-button',
  template: `<button><ng-content /></button>`,
  hostDirectives: [
    HighlightDirective,
    {
      directive: DisabledStateDirective,
      inputs: ['appDisabledState: disabled'],  // remap input name
    },
  ],
})
export class FancyButtonComponent {}

Now in your template you get both behaviors automatically:

HTML
<app-fancy-button>Click Me</app-fancy-button>
<app-fancy-button [disabled]="isDisabled">Submit</app-fancy-button>
Exposing Inputs and Outputs

By default, the host directive's inputs/outputs are not exposed to the parent template. You must explicitly declare which to forward using the inputs and outputs arrays.

TS
// tooltip.directive.ts
import { Directive, Input } from '@angular/core';

@Directive({
  standalone: true,
  selector: '[appTooltip]',
})
export class TooltipDirective {
  @Input() tooltipText = '';
  @Input() tooltipPosition: 'top' | 'bottom' = 'top';
}

TS
@Component({
  standalone: true,
  selector: 'app-icon-button',
  template: `<button><ng-content /></button>`,
  hostDirectives: [
    {
      directive: TooltipDirective,
      inputs: ['tooltipText: tooltip', 'tooltipPosition'],
      // 'tooltipText: tooltip' exposes it as 'tooltip' in the parent template
    },
  ],
})
export class IconButtonComponent {}

HTML
<!-- template usage -->
<app-icon-button tooltip="Delete item" tooltipPosition="bottom">
  🗑️
</app-icon-button>
Chaining Directive Composition

Directives themselves can use hostDirectives, creating chains of composed behaviors.

TS
// interactive-element.directive.ts
@Directive({
  standalone: true,
  selector: '[appInteractive]',
  hostDirectives: [HighlightDirective, FocusRingDirective],
})
export class InteractiveElementDirective {}

// Then use in a component:
@Component({
  standalone: true,
  selector: 'app-card',
  template: `<div><ng-content /></div>`,
  hostDirectives: [InteractiveElementDirective],
})
export class CardComponent {}
Tip
Keep host directives focused on a single concern. Compose small, single-purpose directives rather than large monolithic ones.
Accessing Host Directive Instances

You can inject a composed host directive instance inside the component using Angular's DI:

TS
import { Component, inject } from '@angular/core';

@Component({
  standalone: true,
  selector: 'app-toggle-button',
  template: `<button (click)="toggle()"><ng-content /></button>`,
  hostDirectives: [HighlightDirective],
})
export class ToggleButtonComponent {
  private highlight = inject(HighlightDirective);

  toggle() {
    // Directly control the composed directive
    this.highlight.bg = this.highlight.bg === 'transparent'
      ? '#c8f0c8'
      : 'transparent';
  }
}
Practical Example: Form Field Composition

A real-world use case — compose validation state and error display into a reusable form field:

TS
// required-field.directive.ts
@Directive({
  standalone: true,
  selector: '[appRequiredField]',
  host: { '[class.required]': 'true' },
})
export class RequiredFieldDirective {}

// error-display.directive.ts
@Directive({
  standalone: true,
  selector: '[appErrorDisplay]',
})
export class ErrorDisplayDirective {
  @Input() errorMessage = 'This field is required';
  @HostBinding('attr.aria-invalid') invalid = false;

  showError(show: boolean) {
    this.invalid = show;
  }
}

TS
// form-input.component.ts
@Component({
  standalone: true,
  selector: 'app-form-input',
  imports: [ReactiveFormsModule],
  template: `
    <label>{{ label }}</label>
    <input [formControl]="control" (blur)="onBlur()" />
    @if (showErr) {
      <span class="error">{{ errorDir.errorMessage }}</span>
    }
  `,
  hostDirectives: [
    RequiredFieldDirective,
    {
      directive: ErrorDisplayDirective,
      inputs: ['errorMessage'],
    },
  ],
})
export class FormInputComponent {
  @Input() label = '';
  @Input() control = new FormControl('');
  showErr = false;

  private errorDir = inject(ErrorDisplayDirective);

  onBlur() {
    this.showErr = this.control.invalid;
    this.errorDir.showError(this.control.invalid);
  }
}
Directive Composition vs. Inheritance

Feature

Composition (hostDirectives)

Inheritance (extends)

Reusability

High — compose N behaviors

Limited — single parent only

Coupling

Loose

Tight

Template user control

Can expose or hide inputs

All public API exposed

DI access

inject() from child component

this.xxx from base class

Angular recommendation

Preferred in Angular 15+

Discouraged for directives

Testing Composed Directives

TS
import { TestBed } from '@angular/core/testing';
import { Component } from '@angular/core';
import { FancyButtonComponent } from './fancy-button.component';

@Component({
  standalone: true,
  imports: [FancyButtonComponent],
  template: `<app-fancy-button [disabled]="disabled">Test</app-fancy-button>`,
})
class TestHostComponent {
  disabled = false;
}

describe('FancyButtonComponent (composition)', () => {
  it('applies disabled opacity when disabled=true', async () => {
    const fixture = TestBed.createComponent(TestHostComponent);
    fixture.componentInstance.disabled = true;
    fixture.detectChanges();

    const btn = fixture.nativeElement.querySelector('app-fancy-button');
    expect(btn.style.opacity).toBe('0.5');
  });

  it('highlights on mouseenter', () => {
    const fixture = TestBed.createComponent(TestHostComponent);
    fixture.detectChanges();

    const btn = fixture.nativeElement.querySelector('app-fancy-button');
    btn.dispatchEvent(new MouseEvent('mouseenter'));
    fixture.detectChanges();

    expect(btn.style.backgroundColor).toBeTruthy();
  });
});
Best Practices
  • Keep each host directive focused on ONE behavior (Single Responsibility Principle)

  • Remap input/output names to create a clean public API for the component

  • Use inject() inside the component to coordinate between composed directives

  • Prefer hostDirectives over extending base classes for Angular components

  • Use composition to share behaviors across structurally different components

  • Test each directive in isolation, then test the composed component as a whole

  • All directives used in hostDirectives must be standalone

Warning
All directives used in hostDirectives must be standalone. Module-based directives cannot be composed this way — convert them first using the standalone migration schematic.