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>
If you try it on your own, you'll quickly discover that, unfortunately, it doesn't work, and the console will throw this error:
❗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>
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>
And based on the Angular
documentation, the <ng-template>
is not rendered by default.
*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>
// Shorthand
<span *ngFor="let greenyPlant of ['🌱', '🌿', '🍀']">
{{greenyPlant}}
</span>
// Long form
<ng-template ngFor let-greenyPlant [ngForOf]="['🌱', '🌿', '🍀']">
<span>{{greenyPlant}}</span>
</ng-template>
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
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>
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 {}
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!");
} }
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>
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)
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);
}
}
You'll get exactly <!--container-->
comment node from the console.
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);
}
}
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.
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 {}
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,
});
}
}
}
Let's create a more complex directive using NASA Open APIs
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>
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,
});
});
}
}
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 {}
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>
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');
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`
Finally, binding data to the directive can be achieved as follows:
// The data-binding context remains unchanged
<div *nasaPlanetary="'1998-01-08'; ..."></div>
💡 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>
We'll end up with the following error:
Can't bind to 'date' since it isn't a known property of 'div'.
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>
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;
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>
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.
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)
Great post.
Sometimes it is beneficial to learn new things when deep-diving into Angular's core implementation.
Thank you! You are a tremendous source of inspiration for me as I begin writing my very first blog! 🫶