How to write a custom structural directive in Angular - part 2
In the previous article I've shown how you can implement a custom structural directive in Angular. We've covered a simple custom structural directive that implements interface similar to Angular's NgIf directive. If you don't know what structural directives are, or are interested in basic concepts behind writing custom one, please read the previous articlefirst.
In this article, I will show how to create a more complex structural directive that:
- passes properties into the rendered template
- enables strict type checking for the template variables
Starting point
I am basing this article on the example implemented in the part 1 article. You can use example on Stackblitz as a starting point if you wish to follow along with the code examples.
Custom NgForOf directive
This time, I would like to use Angular's NgForOf directive as an example to re-implement as a custom CsdFor
directive. Let's start off by using Angular CLI to create a new module, and directive files:
ng generate module for
ng generate directive for/for --module for
# or shorthand
# ng g m for
# ng g d for/for --module for
First, we need to follow similar steps as with the CsdIf
directive.
- add constructor with
TemplateRef
, andViewContainerRef
injected - add an
@Input
property to hold the array of items that we want to display
import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';
@Directive({
selector: '[csdFor]',
})
export class ForDirective<T> {
constructor(
private templateRef: TemplateRef<unknown>,
private vcr: ViewContainerRef
) {}
@Input() csdForOf: T[] = [];
}
Then, in the ngOnInit
hook we can render all the items using the provided template:
export class ForDirective<T> implements OnInit {
private items: T[] = [];
constructor(
private templateRef: TemplateRef<unknown>,
private vcr: ViewContainerRef
) {}
@Input() csdForOf: T[] = [];
ngOnInit(): void {
this.renderItems();
}
private renderItems(): void {
this.vcr.clear();
this.csdForOf.map(() => {
this.vcr.createEmbeddedView(this.templateRef);
});
}
}
Now, we can verify that it displays the items properly by adding the following template code to our AppComponent
.
<div *csdFor="let item of [1, 2, 3, 4, 5]">
<p>This is item</p>
</div>
It displays the items correctly, but doesn't allow for changing the displayed collection yet. To implement that, we can modify the csdForOf
property to be a setter and rerender items then:
export class ForDirective<T> {
private items: T[] = [];
constructor(
private templateRef: TemplateRef<unknown>,
private vcr: ViewContainerRef
) {}
@Input() set csdForOf(items: T[]) {
this.items = items;
this.renderItems();
}
private renderItems(): void {
this.vcr.clear();
this.items.map(() => {
this.vcr.createEmbeddedView(this.templateRef);
});
}
}
Now, our custom directive will render the fresh items every time the collection changes (its reference).
Accessing item property
The above example works nice already, but it doesn't allow us to display the item's content yet. The following code will display "no content"
for each template rendered.
<div *csdFor="let item of [1, 2, 3, 4, 5]">
<p>This is item: {{ item || '"no content"' }}</p>
</div>
To resolve this, we need to provide a value of each item into a template that we are rendering. We can do this by providing second param to createEmbeddedView
method of ViewContainerRef
.
export class ForDirective<T> {
/* rest of the class */
private renderItems(): void {
this.vcr.clear();
this.items.map((item) => {
this.vcr.createEmbeddedView(this.templateRef, {
// provide item value here
});
});
}
}
The question is what key do we provide to assign it under item
variable in the template. In our case, the item is a default param, and Angular uses a reserved $implicit
key to pass that variable. With that knowledge, we can finish
the renderItems
method:
export class ForDirective<T> {
/* rest of the class */
private renderItems(): void {
this.vcr.clear();
this.items.map((item) => {
this.vcr.createEmbeddedView(this.templateRef, {
$implicit: item,
});
});
}
}
Now, the content of the item is properly displayed:
Adding more variables to the template's context
Original NgForOf
directives allows developers to access a set of useful properties on an item's template:
index
- the index of the current item in the collection.count
- the length of collectionfirst
- true when the item is the first item in the collectionlast
- true when the item is the last item in the collectioneven
- true when the item has an even index in the collectionodd
- true when the item has an odd index in the collection
We can pass those as well when creating a view for a given element along with the $implicit
parameter:
export class ForDirective<T> {
/* rest of the class */
private renderItems(): void {
this.vcr.clear();
this.items.map((item, index, arr) => {
this.vcr.createEmbeddedView(this.templateRef, {
$implicit: item,
index,
first: index === 0,
last: index === arr.length - 1,
even: (index & 1) === 0,
odd: (index & 1) === 1,
count: arr.length,
});
});
}
}
And now, we can use those properties in our template.
<div
*csdFor="
let item of [1, 2, 3, 4, 5];
let i = index;
let isFirst = first;
let isLast = last;
let isEven = even;
let isOdd = odd;
let size = count
"
>
<p>This is item: {{ item }}.</p>
<pre>
Index: {{ i }}
First: {{ isFirst }}
Last: {{ isLast }}
Even: {{ isEven }}
Odd: {{ isOdd }}
Count: {{ size }}
</pre
>
</div>
Improve template type checking
Lastly, as a developer using the directive it improves, the experience if I can have type checking in the template used by csdFor
directive. This is very useful as it will make sure we don't mistype the property name as well as we only use the item
, and additional properties properly. Angular's compiler allows us to define a static ngTemplateContextGuard
methods on a directive that it will use to type-check the variables defined in the template. The method has a following shape:
static ngTemplateContextGuard(
dir: DirectiveClass,
ctx: unknown): ctx is DirectiveContext {
return true;
}
This makes sure that the properties of template rendered by our DirectiveClass
will need to conform to DirectiveContext
. In our case, this can be the following:
interface ForDirectiveContext<T> {
$implicit: T;
index: number;
first: boolean;
last: boolean;
even: boolean;
odd: boolean;
count: number;
}
@Directive({
selector: '[csdFor]',
})
export class ForDirective<T> {
static ngTemplateContextGuard<T>(
dir: ForDirective<T>,
ctx: unknown
): ctx is ForDirectiveContext<T> {
return true;
}
/* rest of the class */
}
Now, if we eg. try to access item's property that doesn't exist on the item's interface, we will get a compilation error:
<div *csdFor="let item of [1, 2, 3, 4, 5]">
<p>This is item: {{ item.someProperty }}.</p>
</div>
The same would happen if we made a typo in any of the context property names:
<div *csdFor="let item of [1, 2, 3, 4, 5]; let isFirst = firts">
<p>This is item: {{ item }}.</p>
</div>
Summary
In this article, we've created a clone of Angular's built-in NgForOf
directive. The same approach can be used to create any other custom directive that your project might need. As you can see, implementing a custom directive with additional template properties and great type checking experience is not very hard.
If something was not clear, or you want to play with the example directive, please visit the example on Stackblitz.
In case you have any questions, you can always tweet or DM me at @ktrz. I'm always happy to help!