DEV Community

Cover image for The power of structural directives
Jan Hommes
Jan Hommes

Posted on

The power of structural directives

Structural directives are a very powerful tool in Angular, but up till now, I rarely used them. The build-in ones are suitable for nearly every use-case I faced so far.
But lately, I reimplemented the *ngFor with a custom implementation of mine to implement an infinity scroll alternative. That was my personal "wow" effect and for the first time, I understood the full power of structural directives. This article should outline this power, but first, to the basics: What is a structural directive?

What is a structural directive?

I could not better phrase it then the angular documentation:

Structural directives are responsible for HTML layout. They shape or reshape the DOM's structure, typically by adding, removing, or manipulating elements.

So basically: Every time you want to restructure something in the DOM you use a structural directive. The most commonly used ones are *ngIf or *ngFor, which represent a condition or a loop on the template. Here is an example of how you could use a *ngIf on an Angular template:

<button (click)="toggle = !toggle">
  Show
</button>
<div *ngIf="toggle">
  I'm added or removed from the DOM with a structural directive.
</div>
Enter fullscreen mode Exit fullscreen mode

Nothing really new about this. But, what mostly is unknown: You can build your own structural directive. Let's try to reimplement the *ngIf next.

Writing you're own structural directive

Basically it's plain easy to write your own structural directive, as it is just a normal directive. You just need to create a directive:

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

@Directive({
  selector: '[superIf]'
})
export class SuperIfDirective {
  constructor() {
    console.log('init');
  }
}
Enter fullscreen mode Exit fullscreen mode

And you can already add it as a structural directive and as a normal directive:

<div *superIf>Test</div>
<div superIf>Test</div>
Enter fullscreen mode Exit fullscreen mode

When you try that plain example, you will find only one Test output on your app. But the directive was actual initialized two times:

That is because the * is syntactic sugar which will wrap the component where this element is attached within the template. So, in fact the following is the same:

<ng-template [superIf]>
  <div>Test</div>
</ng-template>
Enter fullscreen mode Exit fullscreen mode

You can verify this by using our above *ngIf example. If you change the *ngIf to use the above layout, it will still work the same:

That's basically all of the mystery of structural directives. They are just syntax sugar for writing cleaner templates. As soon as you have access to the ng-template you can manipulate the DOM to your need. The next chapter will show how you roll your own ngIf.

Building your own ngIf

It's really not that hard to build your own ngIf as soon as you understand the basics: A structural directive is syntactic sugar for a directive that is wrapped in an ng-template. That is why you can simply inject the template reference to your directive and use it to attach it to your view.

First, we need to add the TemplateRef and ViewContainerRef:

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

@Directive({
  selector: '[superIf]'
})
export class SuperIfDirective {
  constructor(
    private tpl: TemplateRef<any>,
    private vcr: ViewContainerRef
  ) {}
}
Enter fullscreen mode Exit fullscreen mode

The ViewContainer reference is the point, where your structural directive is placed in the current view. Think of it as an invisible placeholder, which you can append any template to. This is what we are doing in the next step:

  set superIf(expression: boolean) {
    this.vcr.clear();  // 1
    if (expression) {  // 2
      this.vcr.createEmbeddedView(this.tpl);  // 3
    }   
  }
Enter fullscreen mode Exit fullscreen mode

The setter with the same name as the directive will ensure that we get the value that is assigned to our directive. Then we simply always clear the view when the setter is called (1), afterward we check if the expression is truly (2) and if yes, we create the template into our view container "placeholder" (3). The result works exactly as the *ngIf we know of:

Easy, right? You might know that there are more functions. For example, there is an else in the default *ngIf. Also, this is fairly easy to implement when you know of the ability to pass multiple values to a directive:

@Input()
set superIfElse(template: TemplateRef<any>) {
  this.elseTemplate = template;
}
Enter fullscreen mode Exit fullscreen mode

This allows you to pass an else template to the directive either with the structural directive micro syntax: <div *superIf="toggle; else notTrue">.
Or, as this is just sugar, we can also use the directive approached on a template: <ng-template [superIf]="toggle" [superIfElse]="notTrueDirective">

What is left is to check if the else template is set. If it is, and the expression is false, we attach this template instead of the one the directive is placed on:

  @Input()
  set superIf(expression: boolean) {
    this.vcr.clear();
    if (expression) {
      this.vcr.createEmbeddedView(this.tpl);
    } else if(this.elseTemplate) {
      this.vcr.createEmbeddedView(this.elseTemplate);
    }
  }
Enter fullscreen mode Exit fullscreen mode

That's all, you now created an *ngIf replacement. You can see the full example here. If this is useful? I don't think so. Why reinventing the wheel? But it's super useful to understand the concept of structural directives. Next up we will build an actual useful directive.

Doing something useful

So rebuilding the build-in directives is nice for understanding the concept but it doesn't bring any benefit. This chapter tries to implement something easy and at the same time useful.
Therefore we are going to develop an usefulAssign directive which should allow to write cleaner templates. The problem is mostly known to Angular developers: When you request an observable which resolves to an object you often end up in reusing the async pipe over and over again:

<div>
  min: {{(interval$ | async).min}}<br />
  s: {{(interval$ | async).s}}<br />
  ms: {{(interval$ | async).ms}}
</div>
Enter fullscreen mode Exit fullscreen mode

That looks strange and gets very confusing soon. The idea is to use a directive for this to bind to an implicit variable. Then you can use this variable instead of reusing the async pipe over and over again:

<div *usefulAssign="interval$ | async; let timer">
  min: {{timer.min}}<br />
  s: {{timer.s}}<br />
  ms: {{timer.ms}}
</div>
Enter fullscreen mode Exit fullscreen mode

The result: Much cleaner templates and less async pipe usage. The implementation is quite easy, we just need to add a context object to the createEmbeddedView function and the first implicit variable gets the value assigned.

The $implicit is a special type of context, which doesn't need an assignment. You can add more which need to be assigned in the template (e.g. interval$ | async; let foo = bar. If bar is defined in the context as { bar: 'foobar' } then the foo variable contains the foobar string).

The directive itself then looks like this:

As you can see in this example, the value passed to the directive is assigned as an implicit context (1) and therefore available in the template. This allows many different approaches as you can pass any context which you can then reuse in the template easily.

What else?

So this article is just a short introduction and should show what structural directives can do. This is just the tip of the iceberg and some more ideas are:

  • Using it as an infinity scroll solution in combination with paged REST API and the Intersection Observable API. Imagine using *infinityFor="let image of images" and you get infinity scrolling without doing anything more, by simply using the same approach as *ngFor and an additional hidden "load-more" component at the end of the list. This loads more data as soon as it is intersected.
  • Templating for more complex components like tables (like Material CDK does it).
  • dynamic components loading

However, it is always questionable to reinvent the wheel. Don't use a custom structural directive, if a build-in can do the same job.

Top comments (0)