DEV Community

Cover image for <p *>Demystifying the Angular Structural Directives in a nutshell</p>
Khang Tran ⚡️
Khang Tran ⚡️

Posted on • Edited on

<p *>Demystifying the Angular Structural Directives in a nutshell</p>

1. Introduction

I'm sure you're definitely familiar with Angular's commonly used structural directives that we use all the time: ngIf, ngFor, and ngSwitch. But have you ever wondered about the ✨magic✨ behind that little asterisk (*)? In this post, I'll take you on a journey to demystify the power of Angular's micro syntax. Let's dive right in.

Let's begin by trying something NEW that you've probably never done before

🔥 Using *ngIf WITHOUT asterisk (*)

<div ngIf="true">Does it really work 🤔?</div>
Enter fullscreen mode Exit fullscreen mode

If you try it on your own, you'll quickly discover that, unfortunately, it doesn't work, and the console will throw this error:

No provider for TemplateRef found

❗Error: NG0201: No provider for TemplateRef found.

So what exactly is TemplateRef and why does removing the asterisk (*) from the ngIf directive trigger this error? (The answer awaits you in the upcoming section)

🔥 What happens if we use only the asterisk (*), as in the headline of this post?

<p *>Demystifying the Angular Structural Directives 
in a nutshell</p>
Enter fullscreen mode Exit fullscreen mode

Perhaps you'll experience the same sense of surprise I did when I first tried this: no errors, and nothing rendered in the view. It sounds like magic, doesn't it? So WHY does this happen??? 🐣

Everything happens for a reason.

Actually, the above syntax is just the shorthand (syntax desugaring) of <ng-template>. This convention is the shorthand that Angular interprets and converts into a longer form like the following:

<ng-template>
  <p>Demystifying the Angular Structural Directives 
in a nutshell</p>
<ng-template>
Enter fullscreen mode Exit fullscreen mode

And based on the Angular documentation, the <ng-template> is not rendered by default.

Angular <ng-template> documentation


*ngIf and *ngFor behave in a similar fashion. Angular automates the handling of <ng-template> behind the scenes for these directives as well.

// Shorthand
<div *ngIf="true">Just say hello</div>

// Long form
<ng-template [ngIf]="true">
  <div>Just say hello</div>
</ng-template>
Enter fullscreen mode Exit fullscreen mode
// Shorthand
<span *ngFor="let greenyPlant of ['🌱', '🌿', '🍀']">
  {{greenyPlant}}
</span>

// Long form
<ng-template ngFor let-greenyPlant [ngForOf]="['🌱', '🌿', '🍀']">
  <span>{{greenyPlant}}</span>
</ng-template>
Enter fullscreen mode Exit fullscreen mode

You might be curious because, according to the documentation, <ng-template> isn't rendered by default, and we need to specifically instruct it to do so. So, what exactly does specifically instruct mean, and how do *ngIf and *ngFor handle the rendering of <ng-template>?


2. Create our own custom structural directive

Delve into Angular NgIf source code

Angular's NgIf directive source code

The first surprising detail, which might easily go unnoticed without a deeper look into the NgIf source code, is the absence of the asterisk (*) symbol in the NgIf selector. Instead, it ONLY uses plain '[ngIf]' as the selector. (the same applies to other Angular structural directives as well)

From the constructor, you'll find TemplateRef and ViewContainerRef. These are the two key elements required for rendering the template.

TemplateRef

First of all, we need the necessary information to render the template to the DOM, here is where TemplateRef comes into play. You can think of a TemplateRef as a blueprint for generating HTML template content.

<ng-template>
  <p>Everything placed inside ng-template can be
    referenced by using TemplateRef</p>
</ng-template>
Enter fullscreen mode Exit fullscreen mode

Let's take a look at the internal workings of the TemplateRef.

@Directive({
  selector: '[greeting]',
  standalone: true,
})
export class GreetingDirective implements OnInit {
  #templateRef = inject(TemplateRef);

  ngOnInit(): void {
    console.log((this.#templateRef as any)._declarationTContainer.tView.template);
  }
}

@Component({
  selector: 'my-app',
  standalone: true,
  imports: [CommonModule, GreetingDirective],
  template: `
    <p>Demystifying the Angular Structural Directives in a nutshell</p>
    <ng-template greeting>Xin chào - Hello from Viet Nam 🇻🇳!</ng-template>
  `,
})
export class App {}
Enter fullscreen mode Exit fullscreen mode

Under the hood, Angular will translate the <ng-template> into the TemplateRef's instructions, which we can later use to dynamically render the content of the <ng-template> into the view.

function App_ng_template_2_Template(rf, ctx) { if (rf & 1) {
    i0.ɵɵtext(0, "Xin ch\u00E0o - Hello from Viet Nam \uD83C\uDDFB\uD83C\uDDF3!");
} }
Enter fullscreen mode Exit fullscreen mode

ViewContainerRef

Until now, we know how Angular interprets TemplateRef's instructions, but how does Angular handle the process of hooking the template into the view?

Let's return to the previous example and inspect the DOM element.

<!DOCTYPE html>
<html class="ml-js">
  <head>...</head>
  <body>
    <my-app ng-version="16.2.8">
      <!--container-->
    </my-app>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

You'll notice that there is a special comment markup <!--container-->. This is exactly where the template will be inserted into the view.

Specifically, it is a comment node that acts as an anchor for a view container where one or more views can be attached. (view_container_ref.ts)

Angular ViewContainerRef source code

Let's make a minor update to the GreetingDirective to see how it works.

@Directive({
  selector: '[greeting]',
  standalone: true,
})
export class GreetingDirective implements OnInit {
  #vcr = inject(ViewContainerRef);

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

You'll get exactly <!--container--> comment node from the console.

\<!--container--\> demo

So let's put everything together by rendering our <ng-template> into the view.

@Directive({
  selector: '[greeting]',
  standalone: true,
})
export class GreetingDirective implements OnInit {
  #templateRef = inject(TemplateRef);
  #vcr = inject(ViewContainerRef);

  ngOnInit(): void {
    this.#vcr.createEmbeddedView(#templateRef);
  }
}
Enter fullscreen mode Exit fullscreen mode

The final piece is createEmbeddedView. It essentially tells Angular to render the content defined in the TemplateRef and insert it within the element that the ViewContainerRef is associated with.

<ng-template> demo

Link to Stackbliz

Can we do it better?

You may wonder if there is a way to automatically render the <ng-template> instead of handling it manually so tedious. You're right; let's delegate the work to *ngTemplateOutlet.

@Component({
  selector: 'my-app',
  standalone: true,
  imports: [CommonModule, NgTemplateOutlet],
  template: `
    <ng-container *ngTemplateOutlet="greetingTemplate"></ng-container>
    <ng-template #greetingTemplate>Xin chào - Hello from Viet Nam 🇻🇳!</ng-template>
  `,
})
export class App {}
Enter fullscreen mode Exit fullscreen mode

The following is a snippet from the source code of ng_template_outlet.ts. It handles exactly as the whole story we discovered above.

@Directive({
  selector: '[ngTemplateOutlet]',
  standalone: true,
})
export class NgTemplateOutlet<C = unknown> implements OnChanges {
  private _viewRef: EmbeddedViewRef<C>|null = null;

  ngOnChanges(changes: SimpleChanges) {
    ...

    if (this._shouldRecreateView(changes)) {
      // Create a context forward `Proxy` that will always bind to the user-specified context,
      // without having to destroy and re-create views whenever the context changes.
      const viewContext = this._createContextForwardProxy();
      this._viewRef = viewContainerRef.createEmbeddedView(this.ngTemplateOutlet, viewContext, {
        injector: this.ngTemplateOutletInjector ?? undefined,
      });
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Let's create a more complex directive using NASA Open APIs

NASA Planetary Directive Demo

We all know to render the TemplateRef within the view, we simply need to use the createEmbeddedView from ViewContainerRef. However, what if we want to pass the data from our custom directive to the TemplateRef? The answer is Template Context.

Template Context

Inside the <ng-template> tags you can reference variables present in the surrounding outer template. Additionally, a context object can be associated with <ng-template> elements. Such an object contains variables that can be accessed from within the template contents via template (let and as) declarations. ( context)

Understand template context with NgFor

I have no special talents. I am only passionately curious. - Albert Einstein

At the very beginning, when I first attempted to use NgFor, I just accepted the fact that we can use index, odd, even, and other data-binding context without wondering where they came from. It's a bit clearer to me now 🥰.

<div *ngFor="let greenyPlant of ['🌱', '🌿', '🍀']; let i = index"
  [class.even]="even" [class.odd]="odd">
  {{greenyPlant}}
</div>
Enter fullscreen mode Exit fullscreen mode

In addition to the index, even, odd, you can reference all NgFor local variables and their explanation through NgFor documentation.

It's time to create our NASA Planetary Directive

We will create a custom structural directive to fetch the Astronomy Picture of the Day from NASA Open APIs and make the response data, including the title, image, and explanation, available through the template context for later use in our template.

interface NASAPlanetary {
  hdurl: string;
  title: string;
  explanation: string;
}

@Directive({
  selector: '[nasaPlanetary]',
  standalone: true,
})
export class NASAPlanetaryDirective implements OnInit {
  #templateRef = inject(TemplateRef);
  #vcr = inject(ViewContainerRef);
  #http = inject(HttpClient);

  ngOnInit(): void {
    this.#http.get<NASAPlanetary>('https://api.nasa.gov/planetary/apod?api_key=DEMO_KEY')
      .pipe(take(1))
      .subscribe(({ title, hdurl, explanation }) => {
        this.#vcr.createEmbeddedView(this.#templateRef, {
          title, hdurl, explanation,
        });
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

We can bind data to the template context by passing it as a second parameter of createEmbeddedView, as shown in the code above. You can find out more about it through the documentation.

Rendering Astronomy Picture of the Day into the view

@Component({
  selector: 'my-app',
  standalone: true,
  imports: [CommonModule, HttpClientModule, NASAPlanetaryDirective],
  template: `
    <div *nasaPlanetary="
        let hdurl = hdurl; 
        let title = title; 
        explanation as explanation
      " 
      class="mt-5 d-flex justify-content-center">
      <div class="card" style="width: 18rem;">
      <img [src]="hdurl" class="card-img-top" [alt]="title">
      <div class="card-body">
        <h5 class="card-title">{{title}}</h5>
        <p class="card-text explaination">{{explanation}}</p>
      </div>
    </div>
  `,
  styles: [...],
})
export class App {}
Enter fullscreen mode Exit fullscreen mode

You can use either let or as to reference the data-binding context.

Indeed, the above code will be converted to <ng-template> long form as follows:

<ng-template nasPlanetary let-hdurl="hdurl" let-title="title" let-explanation="explanation">
  ...
</ng-template>
Enter fullscreen mode Exit fullscreen mode

Congratulations, you'll receive today's news from the S P A C E 🚀🌌.

Supporting Dynamic Date

But what if we want to retrieve a picture for a specific date rather than today? Let's enable our directive to support dynamic date through the use of the @Input decorator.

@Input('nasaPlanetary') date = new Date().toLocaleDateString('en-CA');
Enter fullscreen mode Exit fullscreen mode

With this input property, you can specify a date in the format YYYY-MM-DD to fetch an image from a particular day. If no date is provided, the directive will default to today.

To ensure that this input property works seamlessly with our directive, we need to make sure that the input alias matches the directive selector, which is nasaPlanetary in our case. So that it is recognized as the default input of the directive.

To fetch an image for a specific date, we'll need to modify the NASA Open APIs endpoint by including the date query parameter:

`https://api.nasa.gov/planetary/apod?date=${this.date}&api_key=DEMO_KEY`
Enter fullscreen mode Exit fullscreen mode

Finally, binding data to the directive can be achieved as follows:

// The data-binding context remains unchanged
<div *nasaPlanetary="'1998-01-08'; ..."></div>
Enter fullscreen mode Exit fullscreen mode

💡 Property bindding issue
Why can't we bind data to a directive input as we do with a normal component input?

<div *nasaPlanentary [date]="'1998-01-08'">
  Property bidding issue
</div>
Enter fullscreen mode Exit fullscreen mode

We'll end up with the following error:

Can't bind to 'date' since it isn't a known property of 'div'.
Enter fullscreen mode Exit fullscreen mode

Let's examine the long version of the code above to understand the underlying reason.

<div [date]="'1998-01-08'">
  <ng-template>Property bidding issue</ng-template>
</div>
Enter fullscreen mode Exit fullscreen mode

Indeed, the date property is applied to the <div> tag instead of <ng-template> itself. That's why Angular considers date property as a property of the <div> tag, rather than the input of the directive. And this is exactly the reason why we got the error Can't bind to 'date' since it isn't a known property of 'div'.

Supporting loading template

We can bind the default input of the directive by giving it the same name as the directive selector. But what if we want to add additional inputs to the directive? Let's achieve this by implementing support for a loading template.

Let's add a new input to support the loading template:

@Input('nasaPlanetaryLoading') loadingTemplate: TemplateRef<any> | null =
    null;
Enter fullscreen mode Exit fullscreen mode

If you notice, the above input follows the naming convention:

💡 Directive input name = directiveSelector + identifier (first character capital)

For the identifier, we can choose whatever we want. In our case, since we want to add this input to support loading purposes, I've named it loading.

So let's define the loading template and add it to the directive:

<div *nasaPlanetary="'1998-01-08'; loading loadingTemplate; ...">

<ng-template #loadingTemplate>
  You can define whatever you want for the loading template.
  We will pass the loading template to the directive
  by using the #templateVariable.
</ng-template>
Enter fullscreen mode Exit fullscreen mode

Final demo

I believe that covers everything I've learned about Angular Structural Directives that I'd like to share in this post. While it may not be the most practical example for real-life projects, I hope you can still find something interesting.

Link to Stackbliz

NASA Planetary Structural Directive DEMO

3. Final thought

The most important thing in writing is to have written. I can always fix a bad page. I can’t fix a blank one. - Nora Roberts

Thank you for making it to the end! This is my very first blog, and I'm thrilled to have completed it. I'm even more delighted if you found something helpful in my blog post. I'd greatly appreciate hearing your thoughts in the comments below, as it would be a significant source of motivation for me to create another one. ❤️


Read More

Master the Art of Angular Content Projection


References

1. Angular documentation
2. Mastering Angular Structural Directives - The basics (Robin Goetz)
3. Unlocking the Power of ngTemplateOutlet - Angular Tiny Conf 2023 (Trung Vo)
4. Structural Directives in Angular – How to Create Custom Directive (Dmytro Mezhenskyi)

Top comments (2)

Collapse
 
phuchieu profile image
Hieu

Great post.
Sometimes it is beneficial to learn new things when deep-diving into Angular's core implementation.

Collapse
 
khangtrannn profile image
Khang Tran ⚡️

Thank you! You are a tremendous source of inspiration for me as I begin writing my very first blog! 🫶