DEV Community

Cover image for Declarative Loop Control Flow in Angular 17
Ilir Beqiri for This is Angular

Posted on

Declarative Loop Control Flow in Angular 17

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>
}
Enter fullscreen mode Exit fullscreen mode

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(); 
}
Enter fullscreen mode Exit fullscreen mode

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(); 
}
Enter fullscreen mode Exit fullscreen mode

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(); 
}
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

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:

Demo how it works with no @if check first

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. 📖

Top comments (3)

Collapse
 
blokche profile image
Guillaume DEBLOCK

Nice and clean! But I don't think a p element is allowed inside a table (or tbody) element.

Collapse
 
danielglejzner profile image
Daniel Glejzner

Great article- keep up the good work !

Collapse
 
jaydevgrowexxer profile image
jaydev-growexxer

nice one