Deep Dive Into Ngif Structural Directive Angular

May 15th, 2020 - written by Kimserey with .

Structural directives in Angular, like ngIf and ngFor are mysterious directives. They both come with custom notation in order to define the content of the directives for example *ngIf="abc else xyz" and *ngFor="let x of xs; let i=index; trackBy: trackBy. In today’s post we will demystify the notation of NgIf, called a microsyntax, by looking at how it can be used, and then reconstructing the directive ourselves.

NgIf Usage

Structural directive in contrast to regular directive modify the DOM by adding or removing part of it. In this post we will be look at NgIf, the structural directive that adds or removes an element depending on a predicate expression provided. For example we can use NgIf as followed:

1
2
3
<div *ngIf="show">
    Hello World
</div>

which will show Hello World if show resolves to true. NgIf also allows us to specify an else condition:

1
2
3
4
5
6
<div *ngIf="show; else noshow">
    Hello World
</div>
<ng-template #noshow>
    No show
</ng-template>

which will show the noshow template referenced by the template reference variable #noshow. We can also name the expression result and use it in the template by using as:

1
2
3
4
5
6
<div *ngIf="(show$ | async) as show; else noshow">
    Hello World {{show}}
</div>
<ng-template #noshow>
    No show
</ng-template>

show can then be used as a variable within the template. NgIf also allows us to define the true condition display elsewhere just like we did for #noshow using then:

1
2
3
4
5
6
7
<div *ngIf="show$ | async; then show; else noshow"></div>
<ng-template #show let-show>
    Hello World {{show}}
<ng-template>
<ng-template #noshow>
    No show
</ng-template>

which will display the show template if shouldShow is true, else noshow. In order to make show accessible in the template, we define it with let-show. We will see later the usage of let-* in more details.

NgIf Desugared Syntax

So far we seen that NgIf directive is used with the asterisk (*) which is the way to use structural directives. And we’ve seen that the string provided to the directive is constructed using the expression resulting in a boolean, plus few keywords; then and else.

The asterisk is actually a syntactic sugar which provides a quick way of using a structural directive. If we take back the previous examples:

1
2
3
<div *ngIf="show">
    Hello World
</div>

would be desugared as:

1
2
3
4
5
<ng-template [ngIf]="show">
  <div>
    Hello world
  </div>
</ng-template>

We have a ng-template containing the div and the directive ngIf is assigned the expression show.

1
2
3
4
5
6
<div *ngIf="show; else noshow">
    Hello World
</div>
<ng-template #noshow>
    No show
</ng-template>

would be desugared as:

1
2
3
4
5
6
7
8
<ng-template [ngIf]="show" [ngIfElse]="noshow">
  <div>
    Hello world
  </div>
</ng-template>
<ng-template #noshow>
    No show
</ng-template>

As we can see, ngIfElse is an input from NgIf which is then translated into a key usable in the syntax else noshow where noshow is the template reference. We will see in the implementation how the template reference is provided in the ngIfElse input.

Lastly the following:

1
2
3
4
5
6
<div *ngIf="(show$ | async) as show; else noshow">
    Hello World {{show}}
</div>
<ng-template #noshow>
    No show
</ng-template>

would be desugared as:

1
2
3
4
5
6
7
8
<ng-template [ngIf]="(show$ | async)" [ngIfElse]="noshow" let-show>
  <div>
      Hello World {{show}}
  </div>
<ng-template>
<ng-template #noshow>
    No show
</ng-template>

We have added as show which is translated into an attribute let-show which create a template variable available in the template. let-show doesn’t require any assignment as it is assigned the $implicit property of the context of the template. The ng-template is a special markup tag from Angular which will not be displayed. It defines a template to be created with a context.

The context is a regular object with a special $implicit property. To demonstrate how ng-template context work, we can use NgTemplateOutlet directive:

1
2
3
4
5
<ng-container *ngTemplateOutlet="myTemplate; context: { $implicit: 'kim', city: 'Paris' }"></ng-container>

<ng-template #myTemplate let-name let-test="city">
  {{name}} - {{test}}
</ng-template>

we would see kim - Paris displayed. Just like NgIf, the NgTemplateOutlet is desugared:

1
2
3
4
5
<ng-template [ngTemplateOutlet]="myTemplate" [ngTemplateOutletContext]="{ $implicit: 'kim', city: 'Paris' }"></ng-template>

<ng-template #myTemplate let-name let-test="city">
  {{name}} - {{test}}
</ng-template>

ngTemplateOutletContext is an input accepting the context for the template. We see that we have set the template context as:

1
{ $implicit: 'kim', city: 'Paris' }

The $implicit property is a special value which can be referenced from within the template using let-[name] without assigning any value to it - the value is implicit. For other variables, we can use let-[name]="[context property]".

Now that we know how to use NgIf and how it is internally represented with ng-template, we can see how we can implement it.

NgIf Implementation

If we were to reimplement the NgIf directive, we would be start by the essential functionality; display the template provided if the expression resolves to true. Starting with this idea, we can make the following directive:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Directive({ selector: '[appIf]' })
export class AppIf {
  @Input()
  set appIf(condition: boolean) {
    if (this.show !== condition) {
      this.show = condition;
      this.updateView();
    }
  }
  // Identifies if the template is being showed.
  show: boolean;

  constructor(
    private templateRef: TemplateRef<any>,
    private viewContainerRef: ViewContainerRef
  ) {}

  updateView() {
    this.viewContainerRef.clear();

    if (this.show) {
      this.viewContainerRef.createEmbeddedView(this.templateRef);
    }
  }
}

In our AppIf directive, we created an input named the same way as the selector appIf. Doing that allows us to use the syntax *prefix="expression" where the expression will set the input of the same name. We then use set to intercept the input property settings where we check if the condition resulting from the expression evaluation is true or false. templateRef and viewContainerRef are injected from the constructor. Every structural directive can inject a TemplateRef<any> which is a reference to the template (ng-template) where the structural directive is attached to. And every directive can inject a ViewContainerRef which is a reference to the container where the directive is attached to.

If true, we use createEmbeddedView to create the template under the view container. If false, the view container is cleared.

This logic represents what the NgIf does when supplied with an expression.

1
2
3
<div *appIf="show">
    Test
</div>

The next functionality of NgIf that we saw is the usage of else keyword. The Angular directive supports defining our own keywords, and else is no different. else is actually defined as an input, using the following convention prefixKey - appIfElse:

1
2
3
4
@Input()
set appIfElse(ref: TemplateRef<any>) {
  // else logic
}

The else input takes a TemplateRef<> as argument in order to display that particular template when the condition is false. Therefore what we need to do is save that template, and show it when the condition changes to false:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@Directive({ selector: '[appIf]' })
export class AppIf {
  @Input()
  set appIf(condition: boolean) {
    if (this.show !== condition) {
      this.show = condition;
      this.updateView();
    }
  }

  @Input()
  set appIfElse(ref: TemplateRef<any>) {
    this.elseRef = ref;
    this.updateView();
  }

  elseRef: TemplateRef<any>;
  show: boolean;

  constructor(
    private templateRef: TemplateRef<any>,
    private viewContainerRef: ViewContainerRef
  ) {}

  updateView() {
    this.viewContainerRef.clear();

    if (this.show) {
      this.viewContainerRef.createEmbeddedView(this.templateRef);
    } else if (!!this.elseRef) {
      this.viewContainerRef.createEmbeddedView(this.elseRef);
    }
  }
}

We are then able to use the directive as such:

1
2
3
4
5
6
<div *appIf="show; else noshow">
  Test
</div>
<ng-template #noshow>
  No Test
</ng-template>

We could have named else whatever we want, all we need to do is to change the name of the input. Following else, then is implemented in the same way:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@Directive({ selector: '[appIf]' })
export class AppIf {
  @Input()
  set appIf(condition: boolean) {
    if (this.show !== condition) {
      this.show = condition;
      this.updateView();
    }
  }

  @Input()
  set appIfElse(ref: TemplateRef<any>) {
    this.elseRef = ref;
    this.updateView();
  }

  @Input()
  set appIfThen(ref: TemplateRef<any>) {
    this.thenRef = ref;
    this.updateView();
  }

  thenRef: TemplateRef<any>;
  elseRef: TemplateRef<any>;
  show: boolean;

  constructor(
    private templateRef: TemplateRef<any>,
    private viewContainerRef: ViewContainerRef
  ) {}

  updateView() {
    this.viewContainerRef.clear();

    if (this.show) {
      this.viewContainerRef.createEmbeddedView(
        !!this.thenRef ? this.thenRef : this.templateRef
      );
    } else if (!!this.elseRef) {
      this.viewContainerRef.createEmbeddedView(this.elseRef);
    }
  }
}

And can be used in the same way:

1
2
3
4
5
6
7
8
9
<div *appIf="show then testThen else testElse">
  Test
</div>
<ng-template #testThen>
  Body
</ng-template>
<ng-template #testElse>
  No test
</ng-template>

The last part we will be looking at is the variables. Template variables can either be defined using let or as notations which in both cases result in a different translations:

  1. *myDir="let b" gets translated into let-b,
  2. *myDir="let b = a" gets translated into let-b="a",
  3. *myDir="a as b" gets translated into let-b="myDir",
  4. *myDir="exp as b" gets translated into [myDir]="exp" let-b="myDir",
  5. *myDir="a; else b as c" gets translated into let-c="myDirElse".

The goal of a template variable is to make a property of the template context available within the template hence:

  1. will set the $implicit property of the context into b,
  2. will set the property a of the context to b,
  3. will set the property myDir to b,
  4. will set the [myDir] input to the expression and set b to the result of the expression,
  5. will set the property myDirElse to c.

Now that we know that, we can update our directive to include the implicit value and some extra value to demonstrate the usage of template variable bindings:

1
2
3
4
5
6
7
8
9
this.viewContainerRef.createEmbeddedView(
  !!this.thenRef ? this.thenRef : this.templateRef,
  {
    $implicit: 'my implicit value',
    extra: 'my extra value',
    appIf: 'my main value',
    appIfElse: 'my else value',
  }
);

We can now modify our template to use the context values:

1
2
3
4
5
6
<div *appIf="show as condition; else testElse as elseVal; let impVal; let extra=extra; extra as extraAs">
  Test {{condition}} - {{elseVal}} - {{impVal}} - {{extra}} - {{extraAs}}
</div>
<ng-template #testElse>
  No test
</ng-template>

which would look as such when desugared:

1
2
3
4
5
6
7
<ng-template 
  [appIf]="show" let-condition="appIf"
  [appIfElse]="testElse" let-elseVal="appIfElse"
  let-impVal 
  let-extra="extra"
  let-extraAs="extra">
</ng-template>

NgIf Grammar

Now that we have seen how to implement support for expressions, key expressions and bindings, we can look at the actual grammar defined by Angular for structural directives:

1
*:prefix="( :let | :expression ) (';' | ',')? ( :let | :as | :keyExp )*"

with the following definitions:

1
2
3
4
5
keyExp = :key ":"? :expression ("as" :local)? ";"?

let = "let" :local "=" :export ";"?

as = :export "as" :local ";"?

Where the portions are:

  • prefix: HTML attribute key,
  • key: HTML attribute key,
  • local: local variable name used in the template,
  • export: value exported by the directive under a given name,
  • expression: Standard Angular expression,

What we see is that a valid argument has to start by a let binding or an expression. Then separates the next expression with an optional separator either ; or , and then allow another let binding or a as binding or a key expression like else. If we take our previous example: *appIf="show as condition; else testElse as elseVal; let impVal; let extra=extra; extra as extraAs", we can decompose the syntax into:

  • show as condition; -> expression,
  • else testElse as elseVal; -> keyExp with key: else, expression: testElse, local: elseVal,
  • let impVal; -> local: impVal,
  • let extra=extra; -> local: extra, export: extra,
  • extra as extraAs -> lcoal: extraAs, export: extra.

In all cases, the colon : and semicolon ; in the notation are optional and are used to increase readability. In some cases, omitting the semicolon makes the notation look better for example for NgForOf, let x of xs looks better than let x; of xs. Just like in other cases have the colon makes the syntax more readable, for example: let x of xs is better than let x of: xs but let x of xs; trackBy: myTrackby is better than let x of xs; trackBy myTrackBy as it shows explicitely that the function myTrackBy is set on the trackBy input.

And that concludes today’s post!

Conclusion

In today’s post we looked at how we could create structural directives in Angular. We explored the different functionality provided by Angular by looking in depth at NgIf, the most commonly used structural directive. We started by looking at how we could use every features provided by NgIf. We then moved on to look at the desugared syntax created from the structural directive. We then moved on to write our own implementation of NgIf. And we completed the post by looking at the grammar definition of the microsyntax provided by Angular to configure structural directives. I hope you like this post and I see you on the next one!

External Sources

Designed, built and maintained by Kimserey Lam.