Welcome to Angular challenges #3.
The aim of this series of Angular challenges is to increase your skills by practicing on real life exemples. Moreover you can submit your work though a PR which I or other can review; as you will do on real work project or if you want to contribute to Open Source Software.
The goal of this third challenge is to dive deep into the power of directive in Angular. Directive allows you to improve the behavior of your template. By listening to HTML tags, specific selectors, you can change the behavior of your view without adding complexity to your template.
Angular already comes with a set of handy directives (in CommonModule): NgIf, NgFor, NgTemplateOutlet, NgSwitch, … that you certainly use in your daily life at work.
One “hidden” feature is that your can customize all directives to your liking; those that live in your project but also those that belong to third party libraries.
In this challenge, we will learn how to enhance the famous and widely used NgFor Directive.
If you haven’t done the challenge yet, I invite you to try it first by going to Angular Challenges and coming back afterward to compare your solution with mine. (You can also submit a PR that I’ll review)
For this challenge, we’ll be working on a very common example. We have a list of persons which can be empty or undefined and we want to display a message if this is the case.
The classic way of doing it will look like this:
<ng-container *ngIf="persons && persons.length > 0; else emptyList">
<div *ngFor="let person of persons">
{{ person.name }}
</div>
</ng-container>
<ng-template #emptyList>The list is empty !!</ng-template>
We first start by checking if our list is empty or undefined. If so, we display an empty message to the user, otherwise we display our list items.
This code works fine and it’s very readable. (One of the fundamental points for a maintainable code). But wouldn’t it be nicer to simplify it and give this management to ngFor which already manages our list ?
We could write something like this, which I find even more readable and we removed one level of depth.
<div *ngFor="let person of persons; empty: emptyList">
{{ person.name }}
</div>
<ng-template #emptyList>The list is empty !!</ng-template>
However we need to override a piece of code we don’t own. How it that possible ? It’s here where we‘ll encounter the power of directive.
Directive is just an Angular class looking for a specific attribute in our template. And multiple directives can look for the exact same attribut in order to apply different actions or modifications.
By knowing this, we can simply create a new directive and use [ngFor] as a selector.
@Directive({
selector: '[ngFor]', // same selector as NgForOf Directive of CommonModule
standalone: true,
})
export class NgForEmptyDirective<T> implements DoCheck {
private vcr = inject(ViewContainerRef);
// same input as ngFor, we just need it to check if list is empty
@Input() ngForOf?: T[] = undefined;
// reference of the empty template to display
@Input() ngForEmpty!: TemplateRef<unknown>;
// reference of the embeddedView of our empty template
private ref?: EmbeddedViewRef<unknown>;
// use ngDoChange if you are never mutating data in your app
ngDoCheck(): void {
this.ref?.destroy();
if (!this.ngForOf || this.ngForOf.length === 0) {
this.ref = this.vcr.createEmbeddedView(this.ngForEmpty);
}
}
}
This directive takes two inputs:
- The list of item to check if empty or undefined.
- The reference to the empty Template to display if above condition is true.
We use the ngDoCheck life cycle instead of ngOnChange because we want to check if the list has changed even if we mutate the list.
ngOnChange will only be triggered if the list has NOT been mutated, however ngDoCheck will be executed at each change detection cycle. If you are 100% certain that you and your team won’t mutate the list, ngOnChange is a better choice!
// immutable operation => trigger ngOnChange
list = [...list, item]
// mutable operation => doesn't trigger ngOnChange
list = list.push(item)
Inside ngDoCheck, we first destroy the emptyTemplateView if it was created and we check if the conditions are made to display the emptyTemplateView otherwise the list items will be rendered by ngFor internal execution.
Drawback: The only drawback I see is we need to import NgFor and NgForEmptyDirective inside our component import array. If we forgot one of them, the component won’t work as expected and we won’t get any warning from our IDE since the compiler cannot know if a directive is needed or not, in comparison of a component.
Since v14.2, this is not completely true anymore: The language service is now warning us for all directives of CommonModule to be imported when used
Angular v15 and higher:
In v15, the Angular team introduce the concept of hostDirective. This is kind of similar to inheritance. We can apply a directive on the host selector of our custom directive or component.
Thanks to this we can get rid of our previous inconvenience. We can now only import our custom directive into our component import array.
// Enhance ngFor directive
@Directive({
selector: '[ngForEmpty]',
standalone: true,
hostDirectives: [
// to avoid importing ngFor in component provider array
{
directive: NgFor,
// exposing inputs and remapping them
inputs: ['ngForOf:ngForEmptyOf'],
},
],
})
class NgForEmptyDirective<T> implements DoChange {
private vcr = inject(ViewContainerRef);
// check if list is undefined or empty
@Input() ngForEmptyOf: T[] | undefined;
@Input() ngForEmptyElse!: TemplateRef<any>;
private ref?: EmbeddedViewRef<unknown>;
ngDoChange(): void {
this.ref?.destroy();
if (!this.ngForEmptyOf || this.ngForEmptyOf.length === 0) {
this.ref = this.vcr.createEmbeddedView(this.ngForEmptyElse);
}
}
}
// we export our directive with a smaller and nicer name
export { NgForEmptyDirective as NgForEmpty };
Warning: We do need to change our selector name and remapped all ngFor hostDirective inputs. If we keep listening on ngFor selector and someone on your team add NgFor or CommonModule to the component array, the list will be render twice.
_In our exemple, we are remapping ngForOf to ngForEmptyOf. To Do so, we write ‘ngForOf:ngForEmptyOf’.
If you want to expose other inputs (like trackBy) from ngFor host directive, you must add them to your inputs array._
Our component template now can be rewritten:
@Component({
standalone: true,
imports: [NgForEmpty], // no need to import ngFor
selector: 'app-root',
template: `
<div *ngForEmpty="let person of persons; else: emptyList">
{{ person }}
</div>
<ng-template #emptyList>The list is empty !!</ng-template>
<button (click)="clear()">Clear</button>
<button (click)="add()">Add</button>
`,
})
export class AppComponent {
persons?: string[] = undefined;
clear() {
this.persons = [];
}
add() {
if (!this.persons) this.persons = [];
this.persons?.push('tutu');
}
}
I hope you enjoyed this NgRx challenge and learned from it.
Other challenges are waiting for you at Angular Challenges. Come and try them. I'll be happy to review you!
Follow me on Medium, Twitter or Github to read more about upcoming Challenges!
Top comments (1)
Interesting example, thanks.