Back to Angular

Directive composition API

adev/src/content/guide/directives/directive-composition-api.md

22.0.0-next.129.3 KB
Original Source

Directive composition API

Angular directives offer a great way to encapsulate reusable behaviors— directives can apply attributes, CSS classes, and event listeners to an element.

The directive composition API lets you apply directives to a component's host element from within the component TypeScript class.

Adding directives to a component

You apply directives to a component by adding a hostDirectives property to a component's decorator. We call such directives host directives.

In this example, we apply the directive MenuBehavior to the host element of AdminMenu. This works similarly to applying the MenuBehavior to the <admin-menu> element in a template.

typescript
@Component({
  selector: 'admin-menu',
  template: 'admin-menu.html',
  hostDirectives: [MenuBehavior],
})
export class AdminMenu {}

When the framework renders a component, Angular also creates an instance of each host directive. The directives' host bindings apply to the component's host element. By default, host directive inputs and outputs are not exposed as part of the component's public API. See Including inputs and outputs below for more information.

Angular applies host directives statically at compile time. You cannot dynamically add directives at runtime.

Directives used in hostDirectives may not specify standalone: false.

Angular ignores the selector of directives applied in the hostDirectives property.

Including inputs and outputs

When you apply hostDirectives to your component, the inputs and outputs from the host directives are not included in your component's API by default. You can explicitly include inputs and outputs in your component's API by expanding the entry in hostDirectives:

typescript
@Component({
  selector: 'admin-menu',
  template: 'admin-menu.html',
  hostDirectives: [
    {
      directive: MenuBehavior,
      inputs: ['menuId'],
      outputs: ['menuClosed'],
    },
  ],
})
export class AdminMenu {}

By explicitly specifying the inputs and outputs, consumers of the component with hostDirective can bind them in a template:

angular-html
<admin-menu menuId="top-menu" (menuClosed)="logMenuClosed()"></admin-menu>

Furthermore, you can alias inputs and outputs from hostDirective to customize the API of your component:

typescript
@Component({
  selector: 'admin-menu',
  template: 'admin-menu.html',
  hostDirectives: [
    {
      directive: MenuBehavior,
      inputs: ['menuId: id'],
      outputs: ['menuClosed: closed'],
    },
  ],
})
export class AdminMenu {}
angular-html
<admin-menu id="top-menu" (closed)="logMenuClosed()"></admin-menu>

Adding directives to another directive

You can also add hostDirectives to other directives, in addition to components. This enables the transitive aggregation of multiple behaviors.

In the following example, we define two directives, Menu and Tooltip. We then compose the behavior of these two directives in MenuWithTooltip. Finally, we apply MenuWithTooltip to SpecializedMenuWithTooltip.

When SpecializedMenuWithTooltip is used in a template, it creates instances of all of Menu , Tooltip, and MenuWithTooltip. Each of these directives' host bindings apply to the host element of SpecializedMenuWithTooltip.

ts
@Directive({
  /* ... */
})
export class Menu {}

@Directive({
  /* ... */
})
export class Tooltip {}

// MenuWithTooltip can compose behaviors from multiple other directives
@Directive({
  hostDirectives: [Tooltip, Menu],
})
export class MenuWithTooltip {}

// CustomWidget can apply the already-composed behaviors from MenuWithTooltip
@Directive({
  hostDirectives: [MenuWithTooltip],
})
export class SpecializedMenuWithTooltip {}

Host directive semantics

Directive execution order

Host directives go through the same lifecycle as components and directives used directly in a template. However, host directives always execute their constructor, lifecycle hooks, and bindings before the component or directive on which they are applied.

The following example shows minimal use of a host directive:

typescript
@Component({
  selector: 'admin-menu',
  template: 'admin-menu.html',
  hostDirectives: [MenuBehavior],
})
export class AdminMenu {}

The order of execution here is:

  1. MenuBehavior instantiated
  2. AdminMenu instantiated
  3. MenuBehavior receives inputs (ngOnInit)
  4. AdminMenu receives inputs (ngOnInit)
  5. MenuBehavior applies host bindings
  6. AdminMenu applies host bindings

This order of operations means that components with hostDirectives can override any host bindings specified by a host directive.

This order of operations extends to nested chains of host directives, as shown in the following example.

typescript
@Directive({...})
export class Tooltip { }

@Directive({
  hostDirectives: [Tooltip],
})
export class CustomTooltip { }

@Directive({
  hostDirectives: [CustomTooltip],
})
export class EvenMoreCustomTooltip { }

In the example above, the order of execution is:

  1. Tooltip instantiated
  2. CustomTooltip instantiated
  3. EvenMoreCustomTooltip instantiated
  4. Tooltip receives inputs (ngOnInit)
  5. CustomTooltip receives inputs (ngOnInit)
  6. EvenMoreCustomTooltip receives inputs (ngOnInit)
  7. Tooltip applies host bindings
  8. CustomTooltip applies host bindings
  9. EvenMoreCustomTooltip applies host bindings

Dependency injection

A component or directive that specifies hostDirectives can inject the instances of those host directives and vice versa.

When applying host directives to a component, both the component and host directives can define providers.

If a component or directive with hostDirectives and those host directives both provide the same injection token, the providers defined by class with hostDirectives take precedence over providers defined by the host directives.

Host directive de-duplication

When the same directive appears more than once in the resolved host directive tree, it is automatically de-duplicated rather than throwing an error. Two deterministic rules are used to decide which match survives.

Template match takes precedence

If a directive matches an element once through a template selector and also appears as a host directive, Angular keeps only the template match and discards all host directive matches.

The mental model is that a host directive match represents Partial<YourDirective> , a partial application where only the inputs and outputs explicitly listed in hostDirectives are exposed, while a template match represents the full directive with its complete public API.

ts
@Directive({selector: '[hoverable]'})
export class Hoverable {}

@Component({
  selector: 'app-button',
  hostDirectives: [Hoverable],
})
export class Button {}
angular-html
<!-- Hoverable is matched by selector AND as a host directive of Button. -->
<!-- Angular keeps only the selector match, which has the full public API. -->
<app-button hoverable></app-button>

Multiple host directive matches are merged

If the same directive appears more than once as a host directive , for example, when two directives both declare a common dependency in their hostDirectives , Angular merges all instances into a single directive instance. The input and output mappings from all instances are combined.

This resolves the classic diamond problem in host directive composition:

ts
// A shared behavior that both triggers need
@Directive({
  host: {
    '[attr.data-trigger-id]': 'triggerId',
  },
})
export class TriggerRef {
  readonly triggerId = `trigger-${crypto.randomUUID()}`;
}

// Two separate triggers, each declaring TriggerRef as a host directive
@Directive({
  selector: '[popoverTrigger]',
  hostDirectives: [TriggerRef],
})
export class PopoverTrigger {
  readonly triggerRef = inject(TriggerRef);
}

@Directive({
  selector: '[dropdownTrigger]',
  hostDirectives: [TriggerRef],
})
export class DropdownTrigger {
  readonly triggerRef = inject(TriggerRef);
}
angular-html
<!-- Angular keeps one TriggerRef instance, shared by both triggers. -->
<button popoverTrigger dropdownTrigger>Actions</button>

HELPFUL: Because Angular produces only one instance of the shared directive, both PopoverTrigger and DropdownTrigger receive the same TriggerRef instance when they inject it.

Conflicting aliases

When Angular merges duplicate host directive matches it also merges their input and output mappings. If two instances of the same host directive expose the same input or output under different aliases, Angular throws an error at compile time (NG8024)

ts
@Directive({
  selector: '[popoverTrigger]',
  hostDirectives: [{directive: TriggerRef, inputs: ['triggerId: popoverTriggerId']}],
})
export class PopoverTrigger {}

@Directive({
  selector: '[dropdownTrigger]',
  hostDirectives: [
    {directive: TriggerRef, inputs: ['triggerId: dropdownTriggerId']}, // different alias!
  ],
})
export class DropdownTrigger {}
angular-html
<!-- Error: triggerId is exposed as both "popoverTriggerId" and "dropdownTriggerId". -->
<button popoverTrigger dropdownTrigger></button>

To resolve this, ensure that both paths expose the shared input or output under the same alias, or do not expose it at all.