Getting Started with Custom Structural Directives in Angular

Introduction

Angular comes with many built-in directives. Some of them (eg. NgIf, NgModel or FormControlDirective) are used daily by Angular developers. Those directives can be split into 2 categories:

  • Attribute directives

They can be used to modify the appearance of behavior of Angular components and DOM elements. For example:

They can be used to manipulate the HTML structure in the DOM. Using them, we can change the structure of part of the DOM that they control. For example:

In this article, I will focus on the latter.

Creating a custom structural directive

As I've mentioned above, there are a couple of built-in structural directives in Angular. However, we might come across a case that the ones provided with the framework don't solve. This is where a custom structural directive might help us resolve the issue. But how do we write one?

All the code examples in this article use the Angular CLI or Nx CLI generated project as a starting point. You can generate a project using the following command, or use Stackblitz starter project.

npx create-nx-workspace --preset=angular --prefix=csd --appName=custom-structural-directive

# or
# ng new custom-structural-directive --prefix=csd

NgIf directive clone

Let's learn the basic concepts by reimplementing the basic features of the NgIf directive. We will call it CsdIf (CSR prefix stands for Custom Structural Directive :))

The structural directive is actually just a regular directive (with some additional syntactic sugars provided by Angular). So we can start with creating a module and empty directive using AngularCLI:

ng generate module if
ng generate directive if/if --module if

# or shorthand
# ng g m if
# ng g d if/if --module if

our new directive should look like this:

import { Directive } from '@angular/core';

@Directive({
  selector: '[csdIf]',
})
export class IfDirective {
  constructor() {}
}

Let's implement the basic functionality of displaying the content if passed value is true.

<h2 *csdIf="true">My visible conditional header</h2>
<h2 *csdIf="false">My hidden conditional header</h2>

To achieve that, we need a couple of elements:

  • an input that will determine whether to show or hide the content (@Input)
  • a reference to the template that we want to conditionally display (TemplateRef)
  • a container that will provide us with access to Angular's view (ViewContainerRef)

The input can be just a regular class property with Angular's @Input decorator. The important thing is to use a proper naming convention. For it to work as it does in the example code shown above, we need to name the property the same as the attribute's selector:

import { Directive, Input } from '@angular/core';

@Directive({
  selector: '[csdIf]',
})
export class IfDirective {
  @Input() csdIf: boolean = false;

  constructor() {}
}

Now our directive has the information whether to display the content or not but we need to also gain access to the TemplateRef and ViewContainerRef instances. We can do that by injecting them via a constructor:

import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';

@Directive({
  selector: '[csdIf]',
})
export class IfDirective {
  @Input() csdIf: boolean = false;

  constructor(
    private templateRef: TemplateRef<unknown>,
    private vcr: ViewContainerRef
  ) {}
}

Now we have all the necessary tools and information to display or hide the content. We can use ViewContainerRef's createEmbeddedView method to display and clear method to remove the content.
Important note: To make sure the csdIf property is assigned already, we need to use ngOnInit lifecycle hook.

import {
  Directive,
  Input,
  OnInit,
  TemplateRef,
  ViewContainerRef,
} from '@angular/core';

@Directive({
  selector: '[csdIf]',
})
export class IfDirective implements OnInit {
  @Input() csdIf: boolean = false;

  constructor(
    private templateRef: TemplateRef<unknown>,
    private vcr: ViewContainerRef
  ) {}

  ngOnInit(): void {
    if (this.csdIf) {
      this.vcr.createEmbeddedView(this.templateRef);
    } else {
      this.vcr.clear();
    }
  }
}

With this implementation, the following example already works as expected.

<h2 *csdIf="true">My visible conditional header</h2>
<h2 *csdIf="false">My hidden conditional header</h2>

There is still a problem with this implementation. Let's try to use the following example:

<input id="showInput" type="checkbox" [(ngModel)]="showInput" />
<label for="showInput">Show conditional header</label>
<h2 *csdIf="showInput">My conditional header</h2>

The "My conditional header" is displayed correctly when the page renders but as soon as we uncheck the showInput, our header doesn't disappear as we would expect. This is because we only check the csdIf input value inside of ngOnInit, but we do not react to the input's changes. To resolve this, we can either use ngOnChanges lifecycle hook or modify the csdIf to be a setter rather than just a property. I will show you the later solution but implementing it using ngOnChanges should be very similar.

As a first step, let's modify the csdIf to be a setter, and store its value in a private property show.

@Directive({
  selector: '[csdIf]',
})
export class IfDirective implements OnInit {
  private show = false;
  @Input() set csdIf(show: boolean) {
    this.show = show;
  }

  /* constructor */

  ngOnInit(): void {
    if (this.show) {
      this.vcr.createEmbeddedView(this.templateRef);
    } else {
      this.vcr.clear();
    }
  }
}

Secondly, when the new csdIf value is set, we need to perform the same logic as we do in ngOnInit. We need to make sure though that we don't render the template twice so we can clear the view first in all cases.

@Directive({
  selector: '[csdIf]',
})
export class IfDirective implements OnInit {
  private show = false;
  @Input() set csdIf(show: boolean) {
    this.show = show;
    this.vcr.clear();
    if (this.show) {
      this.vcr.createEmbeddedView(this.templateRef);
    }
  }

  /* constructor */

  ngOnInit(): void {
    this.vcr.clear();
    if (this.show) {
      this.vcr.createEmbeddedView(this.templateRef);
    }
  }
}

As a final step, let's refactor to remove the code duplication by extracting the common logic into a method.

@Directive({
  selector: '[csdIf]',
})
export class IfDirective implements OnInit {
  private show = false;
  @Input() set csdIf(show: boolean) {
    this.show = show;
    this.displayTemplate();
  }

  /* constructor */

  ngOnInit(): void {
    this.displayTemplate();
  }

  private displayTemplate() {
    this.vcr.clear();
    if (this.show) {
      this.vcr.createEmbeddedView(this.templateRef);
    }
  }
}

Now, our second example works as expected:

<input id="showInput" type="checkbox" [(ngModel)]="showInput" />
<label for="showInput">Show conditional header</label>
<h2 *csdIf="showInput">My conditional header</h2>

Handling additional parameters - else template

The CsdIf directive shows and hides the content based on the boolean input correctly. But the original NgIf directive allows for specifying an alternative template via the "else" property as well. How do we achieve this behavior in our custom directive? This is where understanding the "syntactic sugar" that stands behind the structural directives is crucial. The following NgIf syntax:

<h2 *ngIf="show; else alternativeTemplate">My conditional header</h2>
<ng-template #alternativeTemplate>
  <h2>My alternative header</h2>
</ng-template>

is actually equivalent to the following syntax:

<ng-template [ngIf]="show" [ngIfElse]="alternativeTemplate">
  <h2>My conditional header</h2>
</ng-template>
<ng-template #alternativeTemplate>
  <h2>My alternative header</h2>
</ng-template>

This means that the else property is actually becoming ngIfElse input parameter. In general, we can construct the property name by concatenating the attribute following * and the capitalized property name (eg. "ngIf" + "Else" = "ngIfElse""). In case of our custom directive it will become "csdIf" + "Else" = "csdIfElse

<h2 *csdIf="show; else alternativeTemplate">My conditional header</h2>
<ng-template #alternativeTemplate>
  <h2>My alternative header</h2>
</ng-template>

is equivalent to

<ng-template [csdIf]="show" [csdIfElse]="alternativeTemplate">
  <h2>My conditional header</h2>
</ng-template>
<ng-template #alternativeTemplate>
  <h2>My alternative header</h2>
</ng-template>

By analyzing the "unwrapped" syntax we can notice the the reference to an alternative template is passed via the csdIfElse property. Let's add and handle that property in the custom directive implementation:

@Directive({
  selector: '[csdIf]',
})
export class IfDirective implements OnInit {
  private show = false;

  @Input() set csdIf(show: boolean) {
    this.show = show;
    this.displayTemplate();
  }

  @Input() csdIfElse?: TemplateRef<unknown>;

  /* constructor */

  ngOnInit(): void {
    this.displayTemplate();
  }

  private displayTemplate() {
    this.vcr.clear();
    if (this.show) {
      this.vcr.createEmbeddedView(this.templateRef);
    } else if (this.csdIfElse) {
      this.vcr.createEmbeddedView(this.csdIfElse);
    }
  }
}

This addition makes our directive much more useful, and allows for displaying content for cases when the condition is true or false.

If something is not clear, or you want to play with the example directive please visit the example on Stackblitz.

Real life example

The above example is very simple, but it gives you tools to create your own custom directive when you need it. If you want to have a look at some real-life custom directive example that we've found useful at This Dot Labs, I suggest checking out our route-config open source library. You can read more about it in one of our articles:

Summary

In this article, we've learnt how to write a simple custom structural directive that handles additional inputs. We've covered the syntactic sugar that stands behind the structural directive, and how it translates into directive's inputs. In the second part, I will show you how to add some additional functionalities to the custom structural directive and present ways to improve type checking experience for the custom directive's templates.

In case you have any questions, you can always tweet or DM me at @ktrz. I'm always happy to help!

This Dot Labs is a development consultancy focused on providing staff augmentation, architectural guidance, and consulting to companies.

We help implement and teach modern web best practices with technologies such as React, Angular, Vue, Web Components, GraphQL, Node, and more.

18