Original cover photo by Markus Spiske on Unsplash.
In the previous post we took a look at how we can use dependency injection + directives to both simplify our templates and achieve reusability. In this one, we are going to explore structural directives and how we can make components and directives interoperate and reduce clutter in our .html
files even further.
Let's build a loader component!
The Use Case
Imagine the following scenario: we have a component that loads some data, and we want to show a loading indicator while the data is being fetched. The following criteria should be met:
- We should be able to wrap any template inside our loader component, and it will display a spinner when needed
- The component should receive an input property indicating whether the data is being loaded or not
- The template should be covered by an overlay, so that the user cannot interact (and potentially trigger other HTTP calls) while the data is being loaded
Here is a pretty simple implementation:
@Component({
selector: 'app-loader',
template: `
<div class="loading-container">
<ng-content/>
<div *ngIf="loading" class="blocker">
<p-progressSpinner/>
</div>
</div>`,
standalone: true,
styles: [
`
.loading-container {
position: relative;
}
.blocker {
background-color: black;
position: absolute;
top: 0;
z-index: 9999;
width: 100%;
height: 100%;
opacity: 0.4;
}
`,
],
imports: [NgIf, ProgressSpinnerModule],
})
export class LoaderComponent {
@Input() loading = false;
}
Note: I am using PrimeNG for the examples in this article, but you can easily reuse them with any other implementation
So, here we just project any content that we receive into ng-content
and the rest is some simple CSS + PrimeNG ProgressSpinner
component. The loading
input property is used to toggle the spinner on and off.
Now we can use it in the template as follows:
<app-loader [loading]="loading">
<p>Some content</p>
</app-loader>
Wait, I thought this article is about directives?
Well, good news: it is about directives. But what's the problem with the component? Well, the example of its usage we saw was quite optimistic: real-life scenarios usually are not that simple. Consider this piece of template:
<app-loader [loading]="loading">
<div class="p-grid">
<div class="p-col-12">
<p>Some content</p>
</div>
<app-loader [loading]="otherLoading">
<div class="p-col-12">
<p>Some other content</p>
<app-loader [loading]="evenMoreLoading">
<div class="p-col-12">
<p>Even more content</p>
</div>
</app-loader>
</div>
</app-loader>
</div>
</app-loader>
Now, here, when we have some nested elements, and the template keeps unnecessarily growing, adding more indentation levels, more closing tags, and so on, and so on. What I personally would really love to be able to do is the following:
<p *loading="loading">Some content</p>
But how can we achieve this? Well, we need a directive that does the following things:
- Creates a
LoaderComponent
instance dynamically - Somehow projects the nested template into it
- Keeps them in sync - when the
loading
input property changes, the directive should update theLoaderComponent
instance accordingly - Render the whole stuff
Let's dive into it!
Structural Directives
Structural directives are really cool, because they allow us to reference some templates via TemplateRef
and do all sorts of magic with it.
Also, we can use the ViewContainerRef
to create components dynamically. What will be left for us is to project the template into the component, And yes, this is possible! Let's start simple:
@Directive({
selector: '[loading]',
standalone: true,
})
export class LoaderDirective {
private readonly templateRef = inject(TemplateRef);
private readonly vcRef = inject(ViewContainerRef);
@Input() loading = false;
templateView: EmbeddedViewRef<any>;
loaderRef: ComponentRef<LoaderComponent>;
}
Here we injected the things we need (TemplateRef
and ViewContainerRef
), added a loading
input, naturally, and created two properties: templateView
and loaderRef
. The first one will be used to store the reference to the template that we get, and the second one will be used to store the reference to the ComponentRef
instance that we are going to create - we have to store both.
Next, left do some initial heavy lifting to set the whole thing up:
@Directive({
selector: '[loading]',
standalone: true,
})
export class LoaderDirective implements OnInit {
private readonly templateRef = inject(TemplateRef);
private readonly vcRef = inject(ViewContainerRef);
@Input() loading = false;
templateView: EmbeddedViewRef<any>;
loaderRef: ComponentRef<LoaderComponent>;
ngOnInit() {
this.templateView = this.templateRef.createEmbeddedView({});
this.loaderRef = this.vcRef.createComponent(LoaderComponent, {
injector: this.vcRef.injector,
projectableNodes: [this.templateView.rootNodes],
});
this.loaderRef.setInput('loading', this.loading);
}
}
Here, our ngOnInit
lifecycle method does four things:
- Make the template into an embedded view, so we can render it dynamically
- Create a
LoaderComponent
instance - Project the template into the
LoaderComponent
instance viaprojectableNodes
- here is where the magic is happening! - Set the
loading
input property on theLoaderComponent
instance
Now, this way it will kinda work, but we need two more things to make it work properly:
- We need to update the
LoaderComponent
instance when theloading
input property changes - We need to ensure change detection still works on the projected template despite it being "detached" from the parent view and projected into a new component. We will use
ngDoCheck
for this
Let's finalize the implementation:
@Directive({
selector: '[loading]',
standalone: true,
})
export class LoaderDirective implements OnInit, DoCheck, OnChanges {
private readonly templateRef = inject(TemplateRef);
private readonly vcRef = inject(ViewContainerRef);
@Input() loading = false;
templateView: EmbeddedViewRef<any>;
loaderRef: ComponentRef<LoaderComponent>;
ngOnInit() {
this.templateView = this.templateRef.createEmbeddedView({});
this.loaderRef = this.vcRef.createComponent(LoaderComponent, {
injector: this.vcRef.injector,
projectableNodes: [this.templateView.rootNodes],
});
this.loaderRef.setInput('loading', this.loading);
}
ngOnChanges() {
this.loaderRef?.setInput('loading', this.loading);
}
ngDoCheck() {
this.templateView?.detectChanges();
}
}
Those additions are fairly simple: when the loading
property of the component changes, we update the LoaderComponent
instance accordingly, and when there is change detection running for the directive instance, we also notify the child template in the ngDoCheck
lifecycle method via templateView.detectChanges()
. If you are unfamiliar with how ngDoCheck
works or why it is used, you can read the official docs, or this tutorial.
Now we can simply use it in the template, even when we have multiple nested elements:
<p *loading="loading">
Some content
<span *loading="otherLoading">
Some other content
</span>
<p *loading="evenMoreLoading">
Even more content
</p>
</p>
And so, there is no nested template, no unnecessary indentation, and no unnecessary closing tags. It's just a simple directive that does the job.
You can view the full example with a live demo on StackBlitz:
Conclusion
As mentioned previously, I believe directives are very, very powerful, but sadly underused in the wider community. With this series of articles I want us to explore different use cases where directives help us simplify our templates and improve readability. In the next piece, we will explore using directives to hack into existing components. Stay tuned!
Top comments (1)
great idea for a series,
what about showing example, when users of directive would like to provide a custom loader component template?