Micro-syntax of structural directives — a first look

The two most common built-in structural directives, NgIf and NgFor, are easy to recognize thanks to a naming pattern recommended by the Angular documentation: structural directives are generally prefixed with an asterisk, *. According to the docs, Angular interprets this asterisk as a signal to wrap the host element (the element to which the directive is attached) inside an ng-template:

<div *ngIf="hero">{{hero.name}}</div>
Enter fullscreen mode Exit fullscreen mode

After Angular processes it, the same markup is treated as:

<ng-template [ngIf]="hero">
  <div>{{hero.name}}</div>
</ng-template>
Enter fullscreen mode Exit fullscreen mode

From this expanded form, we can distinguish two complementary ways of approaching structural directives:

  1. By their purpose, as the official docs describe: Structural directives are directives that change the DOM layout by adding and removing DOM elements.
  2. By their implementation: They are directives attached to ng-template elements, and they may define an optional micro-syntax that keeps the template HTML concise and readable.

Directive superpowers - Rendering to the DOM with dependency injection

Because structural directives are implemented as directives, we can tap into Angular's dependency injection (DI) system to our advantage. Since we know the nature of the directive's host, we can acquire it by simply injecting it into our directive.

Here's an illustration of this concept:

@Component({
  // in our app
  selector: 'app',
  // ourDirective is applied to the host component
  template: `<host ourDirective ></host>`,
})
export class AppComponent {}

@Component({
  selector: 'host',
  // the host simply renders the currentName
  template: `{{ currentName }}`,
})
export class HostComponent {
  // by default the currentName is setByTheHost
  currentName = 'setByTheHost';
}

@Directive({
  selector: '[ourDirective]',
})
export class OurDirective implements OnInit {
  // ourDirective uses DI to get access to the HostComponent
  public hostComponent = inject(HostComponent);

  public ngOnInit(): void {
    // after 3 seconds OurDirective sets the hostComponent's currentName as changedByDirective
    setTimeout(() => {
      this.hostComponent.currentName = 'changedByDirective';
    }, 3000);
  }
}
Enter fullscreen mode Exit fullscreen mode

You can see this code in action on Stackblitz.

Injecting the template

As discussed earlier, structural directives are always placed on ng-template elements. Consequently, we can inject Angular's TemplateRef, which carries the instructions needed to render the template into the DOM. Let's examine the code below to see what TemplateRef does internally:

@Component({
  selector: 'my-app',
  template: `<ng-template [ourDirective]>I am in the template</ng-template>`,
})
export class AppComponent {}

@Directive({
  selector: '[ourDirective]',
})
export class OurDirective implements OnInit {
  private template = inject(TemplateRef);

  public ngOnInit(): void {
    console.log(
      (this.template as any)._declarationTContainer.tViews.template + ''
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

This will output the TemplateRef's instructions to the console, revealing how Angular intends to generate our DOM element:

function AppComponent_ng_template_0_Template(rf, ctx) { if (rf & 1) {
i0.ɵɵtext(0, "I am in the template");
} }
Enter fullscreen mode Exit fullscreen mode

Link to Stackblitz

Once we have the rendering instructions, we need a destination for the output. Angular's DI system once more provides the necessary tool: the ViewContainerRef.

Injecting the view container

All Angular components and directives have access to the ViewContainerRef. According to the official docs, it's defined as a container where one or more views can be attached to a component.

Picture it as a virtual container that sits around an anchor point in the DOM. This anchor marks the location where we can dynamically inject new elements. The container is capable of instantiating additional elements on the fly, placing them as siblings next to the anchor element.

The anchor node can be a custom element, a standard HTML element, or even a comment node. Consider this example:

@Component({
  selector: 'my-app',
  template: `
  <our-component></our-component>
  <div ourDirective>On div</div>
  <ng-template ourDirective>On ng-template</ng-template>
  `,
})
export class AppComponent {}

@Directive({
  selector: '[ourDirective]',
})
export class OurDirective {
  private vcr = inject(ViewContainerRef);

  public ngOnInit(): void {
    console.log(this.vcr.element.nativeElement);
  }
}

@Component({
  selector: 'our-component',
  template: `<div>Our Component</div>`,
})
export class OurComponent {
  private vcr = inject(ViewContainerRef);

  public ngOnInit(): void {
    console.log(this.vcr.element.nativeElement);
  }
}
Enter fullscreen mode Exit fullscreen mode

Running this in a Stackblitz project and checking the Chrome console yields the following output:
screenshot from Stackblitz, showing the custom HTMLElement, HTMLDivElement, Comment logged by our directive
As we can see, the native elements of our ViewContainerRef are our custom element, a plain HTML div, and, in the case of the ng-template, a <!--container--> comment. Angular inserts these comment nodes into the DOM for every view it manages. In each instance, we retrieve the DOM anchor that the ViewContainerRef will use as the basis for generating new sibling elements.

Once again, feel free to experiment with the working example on Stackblitz.

Combining the two

With these pieces in hand, we can finally fulfill the official description of structural directives:

Change the DOM layout by adding and removing DOM elements.

Let's build a custom structural directive that renders our template to the DOM not just once, but TWO times! How exciting!

@Directive({
  selector: '[twoTimes]',
})
export class TwoTimesDirective implements OnInit {
  // get the template ref from the ng-template host
  private template = inject(TemplateRef);
  // get the viewcontainerref from the host: <!--comment-->
  private vcr = inject(ViewContainerRef);

  // on initialization of our directive, render our template to the DOM twice
  public ngOnInit(): void {
    this.vcr.createEmbeddedView(this.template);
    this.vcr.createEmbeddedView(this.template);
  }
}
Enter fullscreen mode Exit fullscreen mode

In our directive, we inject both TemplateRef and ViewContainerRef. Inside the ngOnInit lifecycle hook, we generate two sibling elements from the template sourced from the directive's host.

@Component({
  selector: 'my-app',
  template: `
  <p *twoTimes>Two times from asterisk</p>
  <ng-template twoTimes><p>Two times from ng-template</p></ng-template>
  `,
})
export class AppComponent {}
Enter fullscreen mode Exit fullscreen mode

To demonstrate that both forms of our micro-syntax compile correctly, we employ the two alternatives in our AppComponent. The outcome is four elements rendered in total. Each directive instance added two siblings next to the <!--comment--> node from its respective ViewContainerRef:

<my-app ng-version="15.0.2">
  <p>Two times from asterisk</p>
  <p>Two times from asterisk</p>
  <!--container-->

  <p>Two times from ng-template</p>
  <p>Two times from ng-template</p>
  <!--container-->
</my-app>
Enter fullscreen mode Exit fullscreen mode

Link to Stackblitz

The journey continues

Throughout this article, we've taken the initial strides toward a genuine comprehension of structural directives. Yet, we've only grazed the surface, merely peeking beneath the asterisk. To fully harness the capabilities of structural components, we still need to explore how to pass data to our templates through a context object, how to enforce strict template type checking for that context, and how the structural directive syntax is parsed.

So, let's give ourselves a well-deserved pat on the back, take a brief NSDR (Non Sleep Deep Rest) pause to let it all sink in, and look forward to the next installment of our journey toward structural directive mastery.