Control flow block templates were introduced in Angular 17. Like their structural directives counterparts (deprecated in Angular 20), they allow us to control how to show, hide, or repeat elements. Let's look at the differences between the two types and how to migrate them.
*ngIf versus @if and @else
When we want to show or hide elements based on a particular condition, we can use if statements. Figure 1 shows how we can use *ngIf to conditionally show text based on the user's salary.
*ngIf<div class="salary">
<span class="title">Salary: </span>
<ng-container *ngIf="user.salary < 75_000">less than 75,000 </ng-container>
<ng-container *ngIf="user.salary >= 75_000 && user.salary < 100_000">77,000 - 100,000</ng-container>
<ng-container *ngIf="user.salary >= 100_000">greater than 100,000</ng-container>
</div>
Converted to the block template syntax, the same code looks as follow (Figure 2)
@if(), @else if(), and @else()<div class="salary">
<span class="title">Salary: </span>
@if (user.salary < 75_000) { less than 75,000 }
@else if (user.salary < 100_000) { 77,000 - 100,000 }
@else { greater than 100,000 }
</div>
Notice the introduction of @else if @else which was not available with the directives. Although we could do an if / else with the use of <ng-template> (listing 3), the ability to do else if was much harder to achieve.
*ngIf and <ngTemplate><div class="salary">
<span class="title">Salary: </span>
<ng-container *ngIf="user.salary < 75_000; then low else high" />
<ng-template #low>less than 75,000</ng-template>
<ng-template #high>greater than or equal to 75,000</ng-template>
</div>
Another use of if statements was to get the value of a property such as an observable and to assign said value to a variable. Listing 4 for shows us getting the value from an observable users$ and assigning it to a variable users.
*ngIf in conjunction with as<div *ngIf="users$ | async as users">
{{ users.length }} Users
</div>
With the block syntax, the same code looks as follows as seen in Listing 5. The syntax is very similar the addition of a semicolon between the parameter being evaluated and the assignment to the variable.
@if() in conjunction with as@if (users$ | async; as users) {
<div>
{{ users.length }} Users
</div>
}
[ngSwitch] versus @switch
When we have many different conditions results we want conditionally display content against, another option is to use a switch statement. Listing 6 shows conditionally shows a user's status based on their employment status using the directive driven approach. If the status is not found in the list of cases, the switch will display the default value of "Unknown".
[ngSwitch], *ngSwitchCase, and *ngSwitchDefault<div [ngSwitch]="employmentStatus[user.status]" class="status">
<ng-container *ngSwitchCase="'unemployed'">Inactive</ng-container>
<ng-container *ngSwitchCase="'consultant'">Active</ng-container>
<ng-container *ngSwitchCase="'employee'">Active</ng-container>
<ng-container *ngSwitchCase="'retired'">Retired</ng-container>
<ng-container *ngSwitchDefault>Unknown</ng-container>
</div>
Notice that for both the consultant and employee statuses we repeat the contents of <ng-container>. If we want to do a case statement that applies to a OR b, we can set the [ngSwitch] property to trigger on true and then put a condition in the ngSwitchCase as seen lin listing 7.
<div [ngSwitch]="true" class="status">
<ng-container *ngSwitchCase="employmentStatus[user.status] === 'unemployed'">Inactive</ng-container>
<ng-container *ngSwitchCase="
employmentStatus[user.status] === 'consultant'
|| employmentStatus[user.status] === 'employee'
">
Active
</ng-container>
<ng-container *ngSwitchCase="employmentStatus[user.status] === 'retired'">Retired</ng-container>
<ng-container *ngSwitchDefault>Unknown</ng-container>
</div>
The same remains true with the block syntax shown in listing 8. We set the switch statement to a value of true and our case statements are assigned the code to evaluate.
<div class="status">
@switch (true) {
@case(employmentStatus[user.status] === 'unemployed') { Inactive }
@case(
employmentStatus[user.status] === 'consultant'
|| employmentStatus[user.status] === 'employee'
) {
Active
}
@case(employmentStatus[user.status] === 'retired') { Retired }
@default { Unknown }
}
</div>
*ngFor vs @for
To iterate over lists of content we can use a for loop. Listings 9 and 10 show how to iterate over a list of users as well how to assign a track by function.
*ngFor to iterate over a list of users<ul class="users">
<li *ngFor="let user of users$ | async; trackBy: trackById; let i = index">
{{ i + 1 }}: {{ user.firstName }} {{ user.lastName }}
</div>
</li>
<li *ngIf="(users$ | async)?.length === 0" class="no-data">No users found</li>
</ul>
*ngFor in Listing 9public trackById(index: number, user: User): number {
return user.id
}
Converted to the block syntax, we get the following (Listing 11):
@for() to iterate over a list of users<ul class="users">
@for (user of users$ | async; track user.id; let i = $index;) {
<li>
{{ i + 1 }}: {{ user.firstName }} {{ user.lastName }}
</li>
} @empty {
<li class="no-data">No users found</li>
}
</ul>
Track
Note that trackBy: becomes track and that we can have it reference a property in the object directly, we no longer have to rely on a function.
Although not mandatory using *ngFor, track does become required with the use of control flow blocks (@for). The new requirement is important because track is used to optimize performance when handling data changes and allows Angular to maintain a relationship between the data and the DOM to minimize DOM operations when the data changes.
For more about track checkout: Why is track in @for blocks important?.
@empty()
Also notice the use of @empty(). This is a new feature that provides a fallback for when there are no elements in the list. This includes when the list is null or undefined.
Contextual variables
In both the directive and block based examples, we also have a context variable i to represent the index of the item in the list. Table 1 lists the available contextual variables with @for().
@for() blocks| Variable | Type | Meaning |
|---|---|---|
$count | number | Total number of items in the list |
$index | number | Index of the current item |
$first | boolean | Whether the current item is the first item of the list |
$last | boolean | Whether the current item is the last item of the list |
$even | boolean | Whether the current item's index is even |
$odd | boolean | Whether the current item's index is odd |
To define multiple context variables using @for(), we separate them with semi-colons (Listing 12).
@for()<ul class="users">
@for (user of users$ | async; track user.id; let i = $index; let even = $even; let count: $count) {
<li [ngClass]="{ 'stripe': even }">
{{ i + 1 }} of {{ count }}: {{ user.firstName }} {{ user.lastName }}
</li>
}
</ul>
Migrating to the Control Flow Syntax using the CLI
When upgrading a project to Angular 20 using ng upgrade, the CLI will ask us wether we want to upgrade the project to use the Control FLow syntax. Another option is to run the following command:
ng generate @angular/core:control-flow
Regardless of the whether we migrate as part of upgrading to Angular 20 or we run the above command, we will need to then manually go through our code to handle breaking changes with @for() and make sure that the track property automatically assigned as part of the update is sensible and unique.
Tips for choosing track values:
- Must be unique
- Commonly used values include properties such as IDs, or UUIDs
- For static / non-editable lists, we can use the
$index - Avoid using objects as this can lead to significantly slower rendering of updates
Closing Remarks
In this article we covered the differences in syntax between structural directives versus block control flow and how to migrate from one to the other. A project that includes all of the concepts in this article can be found on Github at: github.com/martine-dowden/control-flow.
Happy Coding!

