Angular course · Module 2: Components

Control Flow - the new syntax in Angular 17+

7 min read ·
In this lesson6

So far the template has shown everything, always, in the same form. A real dojo does not work like that. A warrior with no hit points left should see a defeat message instead of the attack button. The list of students is sometimes empty, and then it is better to say that nobody is in the dojo than to show an empty frame. The badge next to a name depends on the rank: one for a master, another for a beginner.

So you need conditions and loops in the template itself. Angular 17 introduced built-in control flow syntax for this: blocks that start with the @ sign and read much like statements in TypeScript. They replaced the older structural directives *ngIf, *ngFor and *ngSwitch.

Why switch to the new syntax? The blocks read like ordinary code, need no imports and refresh lists faster. The old directives still work, but in a standalone component you have to import them (NgIf, NgFor or the whole CommonModule), and since Angular 20 they are marked as deprecated. So in new code, write @ blocks.

@if - the condition at the gate

The @if block displays a part of the template only when a condition is met. You write the condition in round brackets and the content in curly braces. After it you can add any number of @else if branches and one @else branch:

@if (health > 50) {
  <p>Warrior at full strength</p>
} @else if (health > 0) {
  <p>Warrior wounded</p>
} @else {
  <p>Warrior defeated</p>
}

Angular checks the conditions from top to bottom and displays the first branch whose condition is true. With health equal to 30, the first condition is false and the second is true, so "Warrior wounded" appears. The elements of the other branches never reach the page at all: they are not hidden, they simply do not exist. When health changes, Angular switches the branch by itself. The @else branch is optional, so @if (isLoggedIn) { ... } on its own is also a valid block.

In older projects you will see the same kind of condition written with a directive:

<p *ngIf="isLoggedIn; else guestTemplate">Welcome!</p>
<ng-template #guestTemplate>
  <p>Please log in</p>
</ng-template>

The effect is the same, but the notation is harder to read: the else branch has to sit in a separate, named <ng-template> element, and the component has to import NgIf. It is worth recognizing this syntax in other people's code, but in your own, use @if.

@for - reviewing the squad

The @for block repeats a part of the template for each element of a collection. warrior of warriors means: for each element of the warriors array, display the content of the block, and call the current element warrior:

<ul>
  @for (warrior of warriors; track warrior.id) {
    <li>{{ warrior.name }}</li>
  } @empty {
    <li>No warriors in the dojo</li>
  }
</ul>

The track warrior.id part is mandatory. It tells Angular how to recognize each element when the list changes. When a new warrior joins the squad or someone leaves, Angular compares the identifiers and rebuilds only the elements that really changed, instead of redrawing the whole list from scratch. Without track, the template does not compile, and the compiler answers with the message @for loop must have a "track" expression.

It is best to track elements by a unique identifier such as id. When the elements have no identifier and the list never changes its order, you can write track $index, which tracks them by position. The @empty block is optional: Angular displays it when the collection is empty, so the learner sees a message instead of an empty list.

Loop context variables

Inside @for, Angular provides several ready-made variables that describe the current turn of the loop. $index is the element's number counted from zero, $count is the number of all elements, $first and $last say whether the element is the first or the last, and $even and $odd tell even and odd positions apart. You can give them your own names with the word let:

@for (warrior of warriors; track warrior.id; let i = $index, last = $last) {
  <p [class.last]="last">{{ i + 1 }}. {{ warrior.name }}</p>
}

Here i holds the number from $index, so the list starts at "1." and not at "0.". The last variable adds the last CSS class only to the last warrior, for example so that no separator line is drawn below them. You can also use the context variables without an alias, by writing {{ $index + 1 }} directly.

@switch - choosing a badge

When one expression can take one of several known values, use the @switch block instead of a chain of @else if branches. Each @case compares the result of the expression with the given value:

@switch (rank) {
  @case ('master') {
    <span class="badge">Master</span>
  }
  @case ('advanced') {
    <span class="badge">Advanced</span>
  }
  @default {
    <span class="badge">Beginner</span>
  }
}

Angular compares the values with the === operator, so the string '10' does not match the number 10. Only the matching @case is displayed. Unlike the switch statement in JavaScript, there is no fall-through to the following cases, so you never write break. The @default block is optional and only kicks in when no @case matches. It can stand anywhere in the block, but by convention we write it at the end, because that reads most easily.

@let - a variable in the template

Since Angular 18.1 you can declare a variable in the template with the @let block. It comes in handy when the same expression repeats several times or is long enough to deserve a name:

@let fullName = firstName + ' ' + lastName;
@let discountedPrice = price * 0.9;

<h2>{{ fullName }}</h2>
<p>Discounted price: {{ discountedPrice | currency }}</p>

The declaration always ends with a semicolon: @let, a name, an equals sign, an expression and ;. The variable is read-only, so you cannot assign a new value to it, but Angular recalculates it by itself when firstName, lastName or price changes. The | sign passes the value through a pipe, a formatting function: currency turns a number into an amount of money. Such a pipe has to be added to the component's imports as CurrencyPipe from the @angular/common package.

The at sign and braces as plain text

The new syntax has one side effect. Since @ opens a block and } closes it, you can no longer type these characters straight into the text of a template. The address ango@dojo.jp in a paragraph ends in a compilation error, because Angular takes @dojo for the start of a block. Such characters are written as HTML entities:

<p>Write to the master: ango&#64;dojo.jp</p>
<h3>The &#64;if block</h3>
<p>Closing brace: &#125;</p>

The entity &#64; is displayed as @, &#123; as {, and &#125; as }. Double braces are a special case, because Angular decodes entities even before interpolation. If you want to show the text {{ }} literally, add the ngNonBindable attribute to the element, which turns off bindings in its content.

The training hall below is the same component as in the first lesson. Find its Control Flow Demo section: you will see @if with @else if and @else branches, @for with track and @empty, and @switch with @default. The headings of these sections use the &#64; entity, and the interpolation heading uses the ngNonBindable attribute.

Code for this lesson: src/app/app.component.ts
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';

interface Samurai {
  id: number;
  name: string;
  clan: string;
  level: number;
  health: number;
}

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [FormsModule],
  template: `
    <div class="dojo-training">
      <header>
        <h1>Dojo Training Hall</h1>
        <p>Module 2: Angular Components</p>
      </header>

      <section class="training-section">
        <h2>Data Binding Demo</h2>

        <!-- Interpolation -->
        <div class="demo-box">
          <h3 ngNonBindable>Interpolation {{ }}</h3>
          <p>Name: {{ samurai.name }}</p>
          <p>Clan: {{ samurai.clan }}</p>
        </div>

        <!-- Property Binding -->
        <div class="demo-box">
          <h3>Property Binding [ ]</h3>
          <progress [value]="samurai.health" max="100"></progress>
          <p>Health: {{ samurai.health }}%</p>
        </div>

        <!-- Event Binding -->
        <div class="demo-box">
          <h3>Event Binding ( )</h3>
          <button (click)="attack()">Attack!</button>
          <button (click)="heal()">Heal</button>
        </div>

        <!-- Two-way Binding -->
        <div class="demo-box">
          <h3>Two-way Binding [( )]</h3>
          <input [(ngModel)]="samurai.name" placeholder="Samurai name">
          <p>Current name: {{ samurai.name }}</p>
        </div>
      </section>

      <section class="control-flow-section">
        <h2>Control Flow Demo</h2>

        <!-- @if -->
        <div class="demo-box">
          <h3>&#64;if / &#64;else</h3>
          @if (samurai.health > 50) {
            <p class="status good">Samurai in good shape!</p>
          } @else if (samurai.health > 20) {
            <p class="status warning">Samurai weakened</p>
          } @else {
            <p class="status danger">Samurai in critical condition!</p>
          }
        </div>

        <!-- @for -->
        <div class="demo-box">
          <h3>&#64;for</h3>
          <ul>
            @for (s of samuraiList; track s.id) {
              <li [class.selected]="s.id === selectedId" (click)="selectSamurai(s.id)">
                {{ s.name }} (Lvl {{ s.level }})
              </li>
            } @empty {
              <li>No samurai</li>
            }
          </ul>
        </div>

        <!-- @switch -->
        <div class="demo-box">
          <h3>&#64;switch</h3>
          @switch (getRank(samurai.level)) {
            @case ('master') {
              <span class="badge master">Master</span>
            }
            @case ('advanced') {
              <span class="badge advanced">Advanced</span>
            }
            @default {
              <span class="badge beginner">Beginner</span>
            }
          }
        </div>
      </section>
    </div>
  `,
  styles: [`
    .dojo-training {
      font-family: 'Segoe UI', sans-serif;
      max-width: 900px;
      margin: 0 auto;
      padding: 20px;
      background: #1a1a2e;
      color: #eee;
    }

    header {
      text-align: center;
      border-bottom: 2px solid #c9a227;
      padding-bottom: 20px;
      margin-bottom: 20px;
    }

    h1 { color: #c9a227; }
    h2 { color: #c9a227; margin-top: 30px; }
    h3 { color: #888; font-size: 0.9rem; margin-bottom: 10px; }

    .demo-box {
      background: rgba(255,255,255,0.05);
      border: 1px solid #333;
      border-radius: 8px;
      padding: 15px;
      margin-bottom: 15px;
    }

    button {
      background: #c9a227;
      color: #1a1a2e;
      border: none;
      padding: 10px 20px;
      margin: 5px;
      border-radius: 4px;
      cursor: pointer;
    }

    button:hover { opacity: 0.9; }

    input {
      padding: 10px;
      border: 1px solid #c9a227;
      border-radius: 4px;
      background: #16213e;
      color: #eee;
      width: 200px;
    }

    progress {
      width: 100%;
      height: 20px;
    }

    .status { padding: 10px; border-radius: 4px; }
    .status.good { background: #2d6a4f; }
    .status.warning { background: #b45309; }
    .status.danger { background: #991b1b; }

    ul { list-style: none; padding: 0; }
    li {
      padding: 10px;
      margin: 5px 0;
      background: #16213e;
      border-radius: 4px;
      cursor: pointer;
    }
    li:hover { background: #1e3a5f; }
    li.selected { border: 2px solid #c9a227; }

    .badge {
      padding: 5px 15px;
      border-radius: 20px;
      font-weight: bold;
    }
    .badge.master { background: #c9a227; color: #1a1a2e; }
    .badge.advanced { background: #7c3aed; }
    .badge.beginner { background: #4a5568; }
  `]
})
export class AppComponent {
  samurai: Samurai = {
    id: 1,
    name: 'Musashi',
    clan: 'Niten Ichi-ryū',
    level: 8,
    health: 100
  };

  samuraiList: Samurai[] = [
    { id: 1, name: 'Musashi', clan: 'Niten', level: 10, health: 100 },
    { id: 2, name: 'Kojiro', clan: 'Ganryū', level: 9, health: 85 },
    { id: 3, name: 'Jubei', clan: 'Yagyu', level: 7, health: 90 }
  ];

  selectedId = 1;

  attack(): void {
    this.samurai.health = Math.max(0, this.samurai.health - 15);
  }

  heal(): void {
    this.samurai.health = Math.min(100, this.samurai.health + 20);
  }

  selectSamurai(id: number): void {
    this.selectedId = id;
  }

  getRank(level: number): string {
    if (level >= 9) return 'master';
    if (level >= 5) return 'advanced';
    return 'beginner';
  }
}

Remember: @if guards the gate, @for reviews the squad and always needs track, @switch hands out badges, and @let gives a name to what you repeat.

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. What is the new conditional rendering syntax in Angular 17+?

  2. 2. What is required in the new @for block so that Angular can optimally track list items?

  3. 3. Which block do we add to @for to display content when the collection is empty?

Hands-on tasks in the game

  • Click in order

    Arrange the tokens to open a conditional block: @if (isLoggedIn) {

  • Code editor

    Complete the template using @if for conditional display and @for to iterate over the warriors.

  • Vertical ordering

    Arrange the lines of the @switch block in the correct order (top to bottom):

  • Horizontal ordering

    Arrange the single-line tokens to declare a variable in the template: @let fullName = firstName;

Useful articles