Angular 17's Declarative Loop Control Flow

Angular has seen a steady stream of additions and refinements over its recent release cycle, and the momentum shows no signs of slowing. The upcoming version 17 brings another set of notable enhancements, including the new Control Flow Template Syntax.

This feature introduces built-in control flow for templates, replacing the directive-based approach of *ngIf, *ngFor, and *ngSwitch with a new declarative syntax. The shift moves this functionality directly into the framework itself.

The template syntax was a subject of ongoing discussion, with proposals coming from both the Angular team and the community. After weighing the options, the @-syntax—a community-driven proposal—was selected to implement the template control flow.

Details on the reasoning, benefits, and implications are available in the Angular RFC: Built-in Control Flow and the template syntax decision is explained in the Angular blog post.

What drew my attention most was the loop control flow @-for, particularly because of the accompanying @-empty block, which renders a template when the list has no items:

@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 syntax eliminates the need for the ng-container and ng-template elements that were previously required to support *ngIf and *ngFor in templates. The result is more compact and gives a better user experience.

In this piece, I'll walk through how a for loop now looks with the built-in control flow syntax, and clear up a misconception I had about the @-empty block when iterating over asynchronously loaded data (observable results or read-only signal values from toSignal). Let's get to it!

Hands-on! 🐱‍🏍

This demo uses a Products component that displays a list of products in a table. Here's what the iteration looks like 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

Notice the ng-template | ng-container elements. First, there's a check for the null case, guarding against the initial null value from the async pipe. Once the products observable emits, we verify whether the list is empty to show a default message or render the table.

Now, you're likely wondering what this looks like with the new @-for control flow 🤔?

Check this out 😍:

...

@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

There are no ng-container | ng-template elements in sight. The combination of @-for and @-empty removes the need to explicitly check whether the products list is empty and decide which template to show—the default message or the table. That means no more "imperative" checks.

The @-if block takes care of the null check for the async pipe's output. Additionally, the new syntax mandates the use of track, a function that boosts performance. The result: less, cleaner code that's also more efficient and easier to read, write, and comprehend.

My misconception about the @-empty block 😁

When I first started tinkering with the new Control Flow, I assumed the @-empty block paired with @-for would behave like the for await of statement in JavaScript. That is, if the data being iterated arrived asynchronously (from an observable or a read-only signal from toSignal), it would hold off until the data loaded and then determine whether to render the @-empty block.

To put it simply, I believed we could skip the null check while 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 in any programming language that works with a synchronous sequence of values, the same applies to the @-for control flow in templates.

In reality, what happens is that the @-empty block featuring the "No results yet!" message renders first, and then once the data arrives from the server, the products table takes its place:

Demo how it works with no @if check first

While the RFC was open for public review, I posted a comment suggesting an optional condition for the @-empty block (referred to as {: empty} at that time) to allow it to wait for data to load. The Angular team is responsive to community feedback and may well address this behavior in an upcoming release.

FYI: Angular v17 is now officially in a release candidate phase. Feel free to grab it and try out its new features.

Special thanks to @kreuzerk, @danielglejzner and @eugenioz

Thanks for reading!

I hope you found it useful 🙌. If you liked the article, don't hesitate to share it with your friends and colleagues.

For any questions or suggestions, feel free to leave a comment below 👇.

If you find this article informative and don't want to miss future posts, follow me at @lilbeqiri, dev.to, or Medium. 📖