Declarative Loop Control Flow in Angular 17
Ilir Beqiri
Posted on October 20, 2023
In the last few releases, a relatively short time, we witnessed a lot of additions and improvements to the Angular framework, and the future seems to be no worse. Angular Renaissance or Momentum, the next version (v17) will come with some more high qualitative improvements, one amongst them being the new built-in Control Flow Template Syntax.
As the name suggests, this improvement, introduces the built-in control flow for templates, a new declarative syntax of writing control flow in the template, thus providing the functionality of *ngIf
*ngFor
, and *ngSwitch
(directive-based control flow) into the framework itself.
The template syntax has been debated for some time, with the Angular team and community providing their solutions, and after careful reasoning and their trust in the community, the @-syntax
(community proposal) was chosen to back the template control flow.
You can read more about it (reason, benefits, implications … etc) in the Angular RFC: Built-in Control Flow and template syntax choice in the Angular blog post.
Loop control flow @-for
, is what grabbed my attention the most, because of the new supporting @-empty
block that shows a template when no items are in the list:
@for (product of products; track product.title) {
<tr>
<td>{{ product.title }}</td>
<td>{{ product.description }}</td>
<td>{{ product.price }}</td>
<td>{{ product.brand }}</td>
<td>{{ product.category }}</td>
</tr>
} @empty {
<p>No products added yet!</p>
}
This new template syntax allows removing all the ng-container
and ng-template
elements on the template that supported *ngIf
and *ngFor
making templates more compact and offering a better user experience.
In this article, I want to show how a for loop code looks now in the template after using built-in control flow syntax, and a misconception I created for the @-empty
block when iterating over data loaded asynchronously (observable results, or read-only signal values from the toSignal function). Let's dive into an example!
Hands-on! 🐱🏍
This demo will have a Products
component that renders a list of products into a table. Below you see the iteration being done using the current *ngFor
structural directive:
import { Component, inject } from '@angular/core';
import { AsyncPipe, CommonModule } from '@angular/common';
import { ProductService } from '../product.service';
@Component({
selector: 'app-products',
standalone: true,
imports: [CommonModule, AsyncPipe],
template: `
<table>
<thead>
<tr>
<th>Title</th>
<th>Description</th>
<th>Price</th>
<th>Brand</th>
<th>Category</th>
</tr>
</thead>
<tbody>
<ng-container *ngIf="products$ | async as products">
<ng-container *ngIf="products.length; else noResults">
<tr *ngFor="let product of products">
<td>{{ product.title }}</td>
<td>{{ product.description }}</td>
<td>{{ product.price }}</td>
<td>{{ product.brand }}</td>
<td>{{ product.category }}</td>
</tr>
</ng-container>
<ng-template #noResults>
<p>No results yet!</p>
</ng-template>
</ng-container>
</tbody>
</table>
`,
styleUrls: ['./products.component.scss'],
})
export class ProductsComponent {
products$ = inject(ProductService).getProducts();
}
You can see the ng-template | ng-container
elements used, we first check for a null case, guarding against the initial null value emitted from the async pipe, making sure we get the result from the products observable when emitted, and then check if the list is empty to render the default message or render the products in the table.
Now you correctly ask, what would it look like with the new @-for
control flow 🤔?
Look at this code 😍:
...
@Component({
...
template: `
<table>
...
<tbody>
@if (products$ | async; as products) {
@for (product of products; track product.title) {
<tr>
<td>{{ product.title }}</td>
<td>{{ product.description }}</td>
<td>{{ product.price }}</td>
<td>{{ product.brand }}</td>
<td>{{ product.category }}</td>
</tr>
} @empty {
<p>No results yet!</p>
}
}
</tbody>
</table>
`,
...,
})
export class ProductsComponent {
products$ = inject(ProductService).getProducts();
}
No ng-container | ng-template
elements are present. It is the new @-for
and @-empty
in combination that removes the need to check if the products list is empty and what template to render based on that, default message or products table hence no more "imperative" check.
And it's the @-if
block that does the check for the null value emitted from the async pipe. Also, the new syntax enforces the use of track
, a function that improves performance. Thus we have less, cleaner code, more performant, and easy to understand, read, and write.
My misconception for @-empty block 😁
When I first started experimenting with the new Control Flow, I was expecting the @-empty
block in combination with @-for
block to be working under the hood like for await of statement in JavaScript - in the sense that if the data to be iterated loads asynchronously as the result of an observable or a read-only signal created by toSignal function, it would wait until the data is loaded and then decide if the @-empty
block renders or not.
In short, I thought we did not need to check for a null value when waiting for an observable to emit:
...
@Component({
...
template: `
<table>
...
<tbody>
// no @if check here...
@for (product of products$ | async; track product.title) {
<tr>
<td>{{ product.title }}</td>
<td>{{ product.description }}</td>
<td>{{ product.price }}</td>
<td>{{ product.brand }}</td>
<td>{{ product.category }}</td>
</tr>
} @empty {
<p>No results yet!</p>
}
</tbody>
</table>
`,
...,
})
export class ProductsComponent {
products$ = inject(ProductService).getProducts();
}
or when reading from a read-only signal:
...
@Component({
...
template: `
<table>
...
<tbody>
// no @if check here...
@for (product of products(); track product.title) {
<tr>
<td>{{ product.title }}</td>
<td>{{ product.description }}</td>
<td>{{ product.price }}</td>
<td>{{ product.brand }}</td>
<td>{{ product.category }}</td>
</tr>
} @empty {
<p>No results yet!</p>
}
</tbody>
</table>
`,
...,
})
export class ProductsComponent {
products = toSignal(this.productService.getProducts(), { initialValue: null });
}
But just like the if/else statement of any programming language that works on a sync sequence of values, the same stands for the @-for
control flow in templates.
What happens in this case, is that first the @-empty
block with the "No results yet!" message is rendered, and then after the data comes from the server, the products table is rendered:
During the time RFC was open for review, I left a comment asking if an optional condition could be added to the @-empty
block ({: empty} at the time of comment was left) to make it able to wait until data is loaded. The Angular team cares about its community and takes into consideration their input, and it may do something for this behavior in the near future.
FYI: Angular v17 is officially in a release candidate phase now. Feel free to grab and experience its new features.
Special thanks to @kreuzerk , @danielglejzner and @eugenioz
Thanks for reading!
I hope you enjoyed it 🙌. If you liked the article please feel free to share it with your friends and colleagues.
For any questions or suggestions, feel free to comment below 👇.
If this article is interesting and useful to you, and you don't want to miss future articles, follow me at @lilbeqiri, dev.to, or Medium. 📖
Posted on October 20, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.