Angular templates got better - the Control Flow syntax
Angular v17 introduces a new “developer preview” feature called “control flow syntax”.
This feature allows you to use a new template syntax to write control flow statements, like if/else, for, and switch,
instead of using the built-in structural directives (*ngIf
, *ngFor
, and *ngSwitch
).
To understand why this was introduced, let’s see how structural directives work in Angular.
Structural directives under the hood
Structural directives are directives that change the structure of the DOM by adding, removing, or manipulating elements.
They are easy to recognize in Angular because they begin with an asterisk *
.
But how do they really work?
Let’s take a simple template with ngIf
and ngFor
directives as an example:
<h1>Ninja Squad</h1>
<ul *ngIf="condition">
<li *ngFor="let user of users">{{ user.name }}</li>
</ul>
If you read the chapter of our ebook about the Angular compiler,
you know that the framework generates JavaScript code from this template.
And maybe you imagine that *ngIf
gets converted to a JavaScript if
and *ngFor
to a for
loop like:
createElement('h1');
if (condition) {
createElement('ul');
for (user of users) {
createElement('li');
}
}
But Angular does not work exactly like that: the framework decomposes the component’s template into “views”. A view is a fragment of the template that has static HTML content. It can have dynamic attributes and texts, but the HTML elements are stable.
So our example generates in fact three views, corresponding to three parts of the template:
Main view:
<h1>Ninja Squad</h1>
<!-- special comment -->
NgIf view:
<ul>
<!-- special comment -->
</ul>
NgFor view:
<li>{{ user.name }}</li>
This is because the *
syntax is in fact syntactic sugar
to apply an attribute directive on an ng-template
element.
So our example is the same as:
<h1>Ninja Squad</h1>
<ng-template [ngIf]="condition">
<ul>
<ng-template ngFor [ngForOf]="users" let-user>
<li>{{ user.name }}</li>
</ng-template>
</ul>
</ng-template>
Here ngIf
and ngFor
are plain directives.
Each ng-template
then generates a “view”.
Each view has a static structure that never changes.
But these views need to be dynamically inserted at some point.
And that’s where the <!-- special comment -->
comes into play.
Angular has the concept of ViewContainer
.
A ViewContainer
is like a box where you can insert/remove child views.
To mark the location of these containers,
Angular uses a special HTML comment in the created DOM.
That’s what ngIf
actually does under the hood:
it creates a ViewContainer
, and then, when the condition given as input changes,
it inserts or removes the child view at the location of the special comment.
This view concept is quite interesting as it will allow Angular to only update views that consume a signal in the future, and not the whole template of a component! Check out out our blog post about the Signal API for more details.
Custom structural directives
You can create your own structural directives if you want to.
Let’s say you want to write a *customNgIf
directive.
You can create a directive that takes a condition as an input and
injects a ViewContainerRef
(the service that allows to create the view)
and a TemplateRef
(the ng-template
on which the directive is applied).
import { Directive, DoCheck, EmbeddedViewRef, Input, TemplateRef, ViewContainerRef } from '@angular/core';
@Directive({
selector: '[customNgIf]',
standalone: true
})
export class CustomNgIfDirective implements DoCheck {
/**
* The condition to check
*/
@Input({ required: true, alias: 'customNgIf' }) condition!: boolean;
/**
* The view created by the directive
*/
conditionalView: EmbeddedViewRef<any> | null = null;
constructor(
/**
* The container where the view will be inserted
*/
private vcr: ViewContainerRef,
/**
* The template to render
*/
private tpl: TemplateRef<any>
) {}
/**
* This method is called every time the change detection runs
*/
ngDoCheck() {
// if the condition is true and the view is not created yet
if (this.condition && !this.conditionalView) {
// create the view and insert it in the container
this.conditionalView = this.vcr.createEmbeddedView(this.tpl);
} else if (!this.condition && this.conditionalView) {
// if the condition is false and the view is created
// destroy the view
this.conditionalView.destroy();
this.conditionalView = null;
}
} }
This works great! And as you can see, it lets developers like us create powerful structural directives if we want to: the built-in directives offered by Angular are not special in any way.
But this approach has some drawbacks:
for example, it is a bit clunky to have an else
alternative with *ngIf
:
<div *ngIf="condition; else elseBlock">If</div>
<ng-template #elseBlock><div>Else</div></ng-template>
elseBlock
is another input of the NgIf
directive,
of type TemplateRef
, that the directive will display if the condition is falsy.
But this is not very intuitive to use, so we often see this instead:
<div *ngIf="condition">If</div>
<div *ngIf="!condition">Else</div>
The structural directives are also not perfect type-checking-wise.
Even if Angular does some magic (with some special fields called ngTemplateGuard
in the directives to help the type-checker),
some cases are too tricky to handle.
For example, the “else” alternative of *ngIf
is not type-checked:
<div *ngIf="!user; else userNotNullBlock">No user</div>
<ng-template #userNotNullBlock>
<div>
<!-- should compile as user is not null here -->
<!-- but it doesn't -->
{{ user.name }}
</div>
</ng-template>
NgSwitch
is even worse, as it consists of 3 separate directives
NgSwitch
, NgSwitchCase
, and NgSwitchDefault
.
The compiler has no idea if the NgSwitchCase
is used in the right context.
<!-- user.type can be `'user' | 'anonymous'` -->
<ng-container [ngSwitch]="user.type">
<div *ngSwitchCase="'user'">User</div>
<!-- compiles even if user.type can't be 'admin' -->
<div *ngSwitchCase="'admin'">Admin</div>
<div *ngSwitchDefault>Unknown</div>
</ng-container>
It’s also worth noting that the *
syntax is not very intuitive for beginners.
And structural directives depend on the ngDoCheck
lifecycle hook,
which is tied to zone.js
.
In a future world where our components use the new Signal API
and don’t need zone.js
anymore,
structural directives would still force us to drag zone.js
in our bundle.
So, to sum up, structural directives are powerful but have some drawbacks. Fixing these drawbacks would require a lot of work in the compiler and the framework.
That’s why the Angular team decided to introduce a new syntax to write control flow statements in templates!
Control flow syntax
The control flow syntax is a new syntax introduced in Angular v17 to write control flow statements in templates.
The syntax is very similar to some other templating syntaxes you may have met in the past,
and even to JavaScript itself.
There have been some debates and polling in the community about the various alternatives,
and the @-syntax
proposal won.
With the control flow syntax, our previous template with *ngIf
and *ngFor
can be rewritten as:
<h1>Ninja Squad</h1>
@if (condition) {
<ul>
@for (user of users; track user.id) {
<li>{{ user.name }}</li>
}
</ul>
}
This syntax is interpreted by the Angular compiler and creates the same views as the previous template, but without the overhead of creating the structural directives, so it is also a tiny bit more performant (as it uses brand new compiled instructions under the hood in the generated code). As this is not directives, the type-checking is also much better.
And, cherry on the cake, the syntax is more powerful than the structural directives!
The drawback is that this syntax uses @
, {
and }
characters with a special meaning,
so you can’t use these characters in your templates anymore,
and have to use equivalent HTML entities instead (\@
for @
, \{
for {
, and \}
for }
).
If statement
As we saw above, a limitation of NgIf
is that it is a bit clunky to have an else
alternative.
And we can’t have an else if
alternative at all.
That’s no longer a problem with the control flow syntax:
@if (condition) {
<div>condition is true</div>
} @else if (otherCondition) {
<div>otherCondition is true</div>
} @else {
<div>condition and otherCondition are false</div>
}
You can still store the result of the condition in a variable if you want to,
which is really handy when used with an async
pipe for example:
@if (user$ | async; as user) {
<div>User is {{ user.name }}</div>
} @else if (isAdmin$ | async) {
<div>User is admin</div>
} @else {
<div>No user</div>
}
For statement
With the control flow syntax, a for loop needs to specify a track
property,
which is the equivalent of the trackBy
function of *ngFor
.
Note that this is now mandatory, whereas it was optional with *ngFor
.
This is for performance reasons, as the Angular team found that very often,
developers were not using trackBy
when they should have.
<ul>
@for (user of users; track user.id) {
<li>{{ user.name }}</li>
}
</ul>
As you can see, this is a bit easier to use than the trackBy
function of *ngFor
which requires to write a function.
Here we can directly specify the property of the item that is unique,
and the compiler will generate the function for us.
If you don’t have a unique property, you can still use a function or just use the loop variable itself
(which is equivalent to what *ngFor
currently does when no trackBy
is specified).
One of the very useful additions to the control flow syntax is the handling of empty collections.
Previously you had to use an *ngIf
to display a message
if the collection was null
or empty and then use *ngFor
to iterate over the collection.
With the control flow syntax, you can do it with an @empty
clause:
<ul>
@for (user of users; track user.id) {
<li>{{ user.name }}</li>
} @empty {
<li>No users</li>
}
</ul>
We can still access the variables we used to have with *ngFor
:
$index
to get the index of the current item$first
to know if the current item is the first one$last
to know if the current item is the last one$even
to know if the current item is at an even index$odd
to know if the current item is at an odd index$count
to get the length of the collection
Unlike with *ngFor
, you don’t have to alias these variables to use them,
but you still can if you need to, for example when using nested loops.
<ul>
@for (user of users; track user.id; let isOdd = $odd) {
<li [class.grey]="isOdd">{{ $index }} - {{ user.name }}</li>
}
</ul>
It is also worth noting that the control flow @for
uses
a new algorithm under the hood to update the DOM when the collection changes.
It should be quite a bit faster than the algorithm used by *ngFor
,
as it does not allocate intermediate maps in most cases.
Combined with the required track
property,
for
loops should be way faster in Angular applications by default.
Switch statement
This is probably where the new type-checking shines the most, as using an impossible value in a case will now throw a compilation error!
@switch (user.type) {
@case ('user') {
<div>User</div>
} @case ('anonymous') {
<div>Anonymous</div>
} @default {
<div>Other</div>
}
}
Note that the switch statement does not support fall-through, so you can’t have several cases grouped together. It also does not check if all cases are covered, so you won’t get a compilation error if you forget a case. (but I hope it will, add a 👍 on this issue if you want this as well!).
It’s also noteworthy that the @switch
statement uses strict equality (===
) to compare values,
whereas *ngSwitch
used to use loose equality (==
).
Angular v17 introduced a breaking change, and *ngSwitch
now uses strict equality too,
with a warning in the console during development if you use loose equality:
NG02001: As of Angular v17 the NgSwitch directive
uses strict equality comparison === instead of == to match different cases.
Previously the case value "1" matched switch expression value "'1'",
but this is no longer the case with the stricter equality check.
Your comparison results return different results using === vs. ==
and you should adjust your ngSwitch expression and / or values
to conform with the strict equality requirements.
The future of templating 🚀
The control flow syntax is a new “developer preview” feature introduced in Angular v17, and will probably be the recommended way to write templates in the future (the plan is to make it stable in v18 once it has been battle-tested).
It doesn’t mean that structural directives will be deprecated, but the Angular team will likely focus on the control flow syntax in the future and push them forward as the recommended solution.
We will even have an automated migration to convert structural directives to control flow statements in existing applications. The migration is available in Angular v17 as a developer preview. If you want to give it a try, run:
ng g @angular/core:control-flow
This automatically migrates all your templates to the new syntax!
You can also run the migration against a single file with the --path
option:
ng g @angular/core:control-flow --path src/app/app.component.html
Even though the new control flow is experimental,
v17 comes with a mandatory migration needed to support this new control flow syntax,
which consists in converting the @
, {
and }
characters used in your templates to their HTML entities.
This migration is run automatically when you update the app with ng update
.
The future of Angular is exciting!
All our materials (ebook, online training and training) are up-to-date with these changes if you want to learn more!