The Long Wait for Directive Composition
The directive composition api is exactly what its name suggests—a mechanism that lets you attach directives to other directives. On the surface, it sounds almost trivial. One might assume this capability was part of Angular from the very beginning. Yet, the community had to wait over six years for it. The demand first surfaced in a GitHub issue filed on 23 May 2016, a timeline that lines up with the first release candidate of Angular 2, which also landed in May of that year.
Why the Long Delay?
The issue quickly gained traction among Angular developers, becoming one of the most discussed feature requests. About a year and a half in, there was a glimmer of hope: the creator of Angular indicated that the upcoming Ivy compiler would make this feature more attainable.
Once Ivy shipped with Angular 8, the community revived the discussion, pressing for a concrete timeline. Months later, the response came—implementing this would demand significant engineering effort due to underlying data structures that were not compatible with the proposed approach. Finally, on 13 November 2020, directive composition made it onto the official roadmap. With the release of Angular 15, the feature became a reality.
Problems Solved by Directive Composition
Consider a scenario with three components:

These components share certain behaviors: they all need a color-changing capability, and two of them (button and toggle) require a disabled state.
The simplest approach is to add inputs to each component manually.
@Component({
selector: 'app-button',
standalone: true,
template: `<button>
<ng-content></ng-content>
</button>`,
styleUrls: ['./button.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ButtonComponent {
@Input() color: Color = 'primary';
@Input() disableState = false;
@HostBinding('class')
get hostClasses() {
return {
[`${this.color}`]: true,
['disabled']: this.disableState,
};
}
}
This results in repetitive code, violating the DRY principle, so let's look for a better option.
One alternative is to rely on inheritance. You could create a base class that each component extends.
@Component({
selector: 'app-base',
standalone: true,
templateUrl: ''./base.component.html',
styleUrls: ['./base.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BaseComponent {
@Input() color: Color = 'primary';
@Input() disableState = false;
@HostBinding('class')
get hostClasses() {
return {
[`${this.color}`]: true,
['disabled']: this.disableState,
};
}
}
The problem here is that the spinner component now inherits a disabled state it doesn' t need. Exposing an API that will never be used is not considered best practice.
To address this, you could break the functionality into two separate directives—a Disable Directive and a Color Directive. This granular approach keeps each piece of functionality encapsulated.
<app-button appColor appDisable color="primary" [disableState]="true">Click me!</app-button>
<app-toggle appColor appDisable color="secondary" [disableState]="false"></app-toggle>
<app-spinner appColor color="primary"></app-spinner>
These directives would be used inside the component templates. But imagine having numerous toggle instances across your application:
<app-toggle appColor appDisable [disableState]="false" color="primary"></app-toggle>
<app-toggle appColor appDisable [disableState]="true" color="secondary"></app-toggle>
<app-toggle appColor appDisable [disableState]="false" color="secondary"></app-toggle>
<app-toggle appColor appDisable [disableState]="false" color="secondary"></app-toggle>
<app-toggle appColor appDisable [disableState]="true" color="secondary"></app-toggle>
<app-toggle appColor appDisable [disableState]="false" color="secondary"></app-toggle>
Seeing appColor and appDisable in every template gets tedious and reduces readability. This is exactly where the Directive Composition API proves its value.
@Component({
selector: 'app-toggle',
standalone: true,
hostDirectives: [
{
directive: DisableDirective,
inputs: ['disableState: disabled'],
},
{
directive: ColorDirective,
inputs: ['color'],
},
],
template: `<label class="switch">
<input type="checkbox"/>
<span class="slider"></span>
</label> `,
styleUrls: ['./toggle.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ToggleComponent {}
Angular 15 introduced the hostDirectives property. This allows you to define an array of directives that will be automatically applied to your component. To avoid naming conflicts between the host and the composed directives, you can alias their inputs and outputs. A key requirement: all directives listed in hostDirectives must be standalone.
Let's see what our toggles look like now:
<app-toggle [disabled]="false" color="secondary"></app-toggle>
<app-toggle [disabled]="true" color="primary"></app-toggle>
<app-toggle [disabled]="false" color="primary"></app-toggle>
<app-toggle [disabled]="false" color="primary"></app-toggle>
<app-toggle [disabled]="true" color="primary"></app-toggle>
<app-toggle [disabled]="false" color="primary"></app-toggle>
Much cleaner and easier to read.
While we demonstrated composing directives with components, you can also compose directives with other directives.
@Directive({
selector: '[appToggleTheme]',
standalone: true,
hostDirectives: [
{
directive: DisableDirective,
inputs: ['disableState: disabled'],
},
{
directive: ColorDirective,
inputs: ['color'],
},
],
})
export class ToggleThemeDirective implements AfterViewInit {
@Input() isRounded = true;
constructor(private elRef: ElementRef) {}
ngAfterViewInit() {
const sliderRef = this.elRef.nativeElement.querySelector('.slider');
if (sliderRef && this.isRounded) {
sliderRef.classList.add('slider-round');
}
}
}
In this example, ToggleThemeDirective is built by combining ColorDirective and DisableDirective, while also adding its own behavior. You can then apply the ToggleThemeDirective like this:
@Component({
selector: 'app-toggle',
standalone: true,
hostDirectives: [{ directive: ToggleThemeDirective, inputs: ['isRounded'] }],
template: `<label class="switch">
<input type="checkbox" />
<span class="slider round"></span>
</label> `,
styleUrls: ['./toggle.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ToggleComponent {}
Execution Order of Directives
Host directives follow the same lifecycle rules as any other directive or component within a template. One crucial point: the constructors, lifecycle hooks, and host bindings of host directives are always invoked before those of the component or directive they are attached to.
Let's trace the order for a Toggle Component that uses ToggleThemeDirective. As mentioned, this directive combines the Disable and Color directives with some extra logic. Suppose each of these directives only implements the ngOnInit lifecycle hook.
The execution sequence would be:
- Disable directive constructor
- Color directive constructor
- ToggleTheme directive constructor
- Disable directive
ngOnInit - Color directive
ngOnInit - ToggleTheme directive
ngOnInit - Disable directive host bindings
- Color directive host bindings
- ToggleTheme directive host bindings
Performance Considerations
While the convenience is undeniable, careless use can lead to memory overhead. Take the Toggle Component from our example: composing it with two directives means that after rendering, three objects are created for each instance—the toggle itself plus the two directives. With a few toggles, this is negligible. But in a large data table with hundreds of instances, the cumulative effect becomes noticeable.
Final Thoughts
The Directive Composition API is a robust and long-awaited feature that can significantly enhance code quality and template readability. However, its adoption requires careful judgment, as it might create performance bottlenecks in contexts where it isn't an ideal fit.
