13 Nov 2023
5 min

Work smart, not hard – use Directive Composition API

In this article, you will find out what directive composition api is, why it took so long to be released, what problems it solves and what are its benefits and downsides. 

So, what is directive composition API? It’s a feature which allows you to compose directives with other directive, and.. that’s all! Seems basic, right? We could wonder, shouldn’t this feature be available since the early releases of Angular? This question has been around for a while, more precisely since 23rd May of 2016, when this issue was created, and this date coincides with the publication of Angular 2 first release candidate, which occurred in May 2016.

Why did it took so long?

The Recently mentioned issue was really popular across the angular community, and nearly  1.5 years later, a light at the end of the tunnel appeared. The creator of angular announced that after the introduction of the ivy compiler it will be more feasible.

After Ivy introduction in Angular 8, the community refreshed the issue and again asked for upcoming plans regarding this feature, and several months later, they got it – supporting such a feature would need some major engineering because of not compatible data structures. At last, on 13th November of 2020, directive composition was included in the official roadmap. With the 15th angular version, the long-awaited feature is finally introduced.

What problems directive composition solves? 

Let’s take a look at a use case. Imagine that you have three components

GIF

which share some functionality – color change (all of them), disabled state (button and toggle).

The most basic way to achieve this is to set up inputs for each of the components.

@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 approach violates DRY principle, so let’s take a different approach.

One of possible solutions is to use inheritance. We can create a Base Component, which each component will extend.

@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,
    };
  }
}

n that case, the spinner component has support for a disabled state, which is irrelevant for it, and overall, it is not the best practice to expose an api which will not be used.

To eliminate that, we could create two directives – Disable Directive and Color Directive. This way our code will be granulated for each functionality.

<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 will always be used within the scope of the component. Image that there is a lot of toggle instances: 

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

appColor and appDisable are quite redundant in a template, which is not really convenient to read, and here Directive Composition API comes handy.

@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 {}

Since Angular 15, there is a new property called hostDirectives. It allows you to specify an array of directives, which should be applied to our components. You can also set aliases for inputs and outputs to prevent conflicts between directive and host elements. It is important to remember that directives within hostDirectives have to be standalone.  

Now let’s take a look on our toggles

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

It’s much more readable! 

In our case, we composed directives with components, but it is also possible to 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');
    }
  }
}

Here, we’ve got ToggleThemeDirective, which is composed of ColorDirective, DisableDirective, and also has its own functionality. After that, we just have to apply our ToggleThemeDirective as follows: 

@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 {}

Directives’ execution order

Host Directives have the same lifecycle as other components and directives in a template. It is important to remember that constructor execution, lifecycle hooks and bindings of host directives always occur before the directive or component which they are applied to. 

Let’s analyze the execution order of the Toggle Component with ToggleThemeDirective. This directive, as shown earlier, is composed of Disable, Color directives with some additional functionality. Let’s assume that those directives have only an ngOnInit lifecycle hook implemented.

Execution order would look like that: 

  1. Disable directive constructor execution
  2. Color directive constructor execution
  3. ToggleTheme directive constructor execution
  4. Disable directive ngOnInit 
  5. Color directive ngOnInit 
  6. ToggleTheme directive ngOnInit 
  7. Disable directive host bindings application
  8. Color directive host binding application
  9. ToggleTheme directive host binding application

Performance

While this feature offers great convenience, it can also lead to memory-related challenges if not used thoughtfully. For example, let’s take a look at the Toggle Component. It is composed of two directives, resulting in the creation of three objects after rendering, including the toggle object. This might not pose a noticeable issue for a small number of toggles, but when dealing with a larger quantity, such as within a substantial table, the impact becomes evident.

Summary

Directive composition API is a powerful, long-anticipated feature that can be used to improve your code quality and readability. However, its utilization should be approached with care, as it may introduce performance issues in scenarios where it is not well-suited.

Thanks for reading!

Frequently asked questions

How do I use hostDirectives to compose directives in Angular?

Add a hostDirectives array to the @Component or @Directive decorator and list each directive there. Since Angular 15, the host element automatically gets the behavior of every listed directive. For example, hostDirectives: [{ directive: ColorDirective, inputs: ['color'] }] exposes the color input on the host without repeating appColor in templates. Each entry can also alias inputs and outputs to avoid naming conflicts with the host.

Is directive composition a better alternative to component inheritance in Angular?

Often yes, because a base component forces every subclass to expose inputs it does not use, such as a disableState input on a spinner. With directive composition, you split shared behavior into small directives like DisableDirective and ColorDirective, and each component applies only the ones it needs via hostDirectives. This keeps each component's public API minimal and avoids repeating the same input logic in every template.

Do directives used in hostDirectives have to be standalone?

Yes, every directive listed in hostDirectives must be standalone, so it can be applied without being declared in an NgModule. Directives can be composed into components and also into other directives, such as a ToggleThemeDirective that wraps DisableDirective and ColorDirective and adds its own logic. Use inputs and outputs aliases in each entry when names clash with the host's own bindings.

In what order do host directives run their constructors, ngOnInit and host bindings in Angular?

Host directives initialize before the component or directive they are applied to. Their constructors run first, then their lifecycle hooks such as ngOnInit, and finally their host bindings are applied, all before the host's equivalent steps. For a toggle component composed of DisableDirective, ColorDirective and ToggleThemeDirective, all constructors run first, followed by every ngOnInit, and then every host binding is applied.

Does using hostDirectives hurt rendering performance in Angular?

It can, because each host directive is instantiated as an additional object alongside the host. A toggle component with two host directives creates three objects after rendering, which is negligible for a few instances but becomes measurable in large collections such as a big table with many rows. Use hostDirectives where it improves readability and reuse, and avoid stacking many directives on components rendered in large numbers.

Share this post

Sign up for our newsletter

Stay up-to-date with the trends and be a part of a thriving community.