| |

Angular 128 🅰️ Migrating to New Control Flow

⏱️ Estimated read: 14 min | |

Angular 17 introduced a new built-in control flow syntax that replaces the structural directives *ngIf, *ngFor, and *ngSwitch with blocks prefixed by @. The new syntax is baked into the template, which means CommonModule is no longer required for these directives, and the compiler can pre-compile the blocks for better performance and type checking.

This chapter covers the new syntax, the migration schematic that converts existing templates, the manual changes the schematic cannot handle, and the patterns for adopting control flow incrementally.

Key point: The new control flow syntax uses @if, @else if, @else, @for, @empty, @switch, @case, and @default. Run ng generate @angular/core:control-flow to migrate automatically. The schematic handles most cases, but track expressions in @for loops often need manual refinement, and some edge cases like @for nested under @switch cannot be migrated automatically.


Why the new control flow syntax exists

The performance problem. Structural directives like *ngIf and *ngFor are executed at runtime by the Angular framework. The new blocks are pre-compiled by the Angular compiler, so the component’s output already includes only the minimum runtime JavaScript needed. This makes them faster and slightly smaller.

The readability problem. The @ syntax is closer to JavaScript and TypeScript than the directive syntax. @if, @for, and @switch resemble their JavaScript counterparts, and the block structure is explicit. The Angular team surveyed thousands of developers and found that @ syntax was preferred by a nearly 2:1 margin over the alternative # syntax.

The CommonModule problem. The structural directives required importing CommonModule in every component that used them. The new blocks are built into the template syntax, so the import is no longer needed. This simplifies component declarations and can lead to a slight reduction in bundle size.

The trackBy problem. The *ngFor directive used a trackBy function to identify items. The new @for block requires a track expression, which is mandatory. This forces developers to think about item identity, which improves rendering performance when used correctly.

The type-checking problem. The new control flow blocks have better type checking than the old directives. Type narrowing works in @if blocks, and the track expression is type-checked against the collection’s item type.


a. The @if, @else if, and @else blocks

The @if block conditionally displays its content when the expression is truthy. It replaces *ngIf.

@if (a > b) {
  <p>{{ a }} is greater than {{ b }}</p>
}

The @else if and @else blocks provide alternatives. The old *ngIf with an else template reference is replaced by a single block structure.

@if (a > b) {
  <p>{{ a }} is greater than {{ b }}</p>
} @else if (b > a) {
  <p>{{ a }} is less than {{ b }}</p>
} @else {
  <p>{{ a }} is equal to {{ b }}</p>
}

The @if block supports saving the result of the conditional expression into a variable for reuse inside the block:

@if (user.profile.settings.startDate; as startDate) {
  {{ startDate }}
}

This is useful for longer expressions that would be repeated in the template.


b. The @for, track, and @empty blocks

The @for block loops through a collection. The track expression is mandatory and identifies each item for DOM reuse.

@for (item of items; track item.id) {
  {{ item.name }}
}

The track expression is the replacement for trackBy. It tells Angular how to map data items to DOM nodes so that when the data changes, Angular performs the minimum necessary DOM operations. A unique identifier like id or uuid is the best choice. If the collection is static, $index can be used. The identity option tracks by object reference and should be avoided when possible because it can lead to slower rendering updates.

The @empty block displays a fallback when the collection is empty.

@for (item of items; track item.id) {
  <li>{{ item.name }}</li>
} @empty {
  <li>There are no items.</li>
}

Contextual variables are available inside @for blocks: $count, $index, $first, $last, $even, and $odd. These can be aliased with a let segment.

@for (item of items; track item.id; let idx = $index, e = $even) {
  <p>Item #{{ idx }}: {{ item.name }}</p>
}

c. The @switch, @case, and @default blocks

The @switch block selects a case based on a conditional expression. It replaces [ngSwitch] and *ngSwitchCase.

@switch (userPermissions) {
  @case ('admin') {
    <app-admin-dashboard />
  }
  @case ('reviewer') {
    <app-reviewer-dashboard />
  }
  @default {
    <app-viewer-dashboard />
  }
}

The value of the conditional expression is compared to the case expression using the triple-equals (===) operator. @switch does not have fallthrough, so break statements are not needed.

A known limitation is that @for cannot be nested under @switch, and @case must be nested directly under @switch. A pattern that generated @case blocks from an array with *ngFor cannot be migrated directly and must be rewritten as a @for block with @if.


d. Running the migration schematic

The official migration schematic converts existing templates from the old directives to the new blocks.

ng generate @angular/core:control-flow

The schematic asks which path to migrate. The default is the entire application, but the migration can be scoped to a specific directory with the --path option.

ng generate @angular/core:control-flow --path=src/app/employees

The schematic also asks whether to reformat the templates. Answering yes applies the project’s formatter to the migrated templates.


e. Manual changes the schematic cannot handle

The migration schematic handles most cases, but some require manual attention.

The track expression. The old *ngFor often did not have a trackBy function. The schematic generates a track expression, but it cannot know which property is the unique identifier. It may use the object reference or $index, which is not optimal. The developer must review the generated track expressions and replace them with a unique property like id.

<!-- Generated by schematic (may not be optimal) -->
@for (item of items; track item) { }

<!-- Improved manually -->
@for (item of items; track item.id) { }

The @empty block. The old *ngFor did not have a built-in empty state. The schematic cannot invent one. The developer should add an @empty block when the application has a fallback for empty collections.

The @for under @switch case. A pattern that generated @case blocks from an array with *ngFor cannot be migrated directly. The schematic refuses to migrate it, and the manual solution is to rewrite it as a @for block with @if.

Escaping @ and }. Templates that use @ or } as literal characters need those characters escaped. The migration handles this, but the developer should verify that no unintended escapes were introduced.


Complete Example Session

<!-- ============================================
PART 1: *ngIf TO @if
============================================ -->
<!-- Before -->
<div *ngIf="isVisible">Content</div>

<!-- After -->
@if (isVisible) {
  <div>Content</div>
}
<!-- ============================================
PART 2: *ngIf WITH ELSE TO @if @else
============================================ -->
<!-- Before -->
<div *ngIf="isLoggedIn; else loggedOut">Welcome</div>
<ng-template #loggedOut>Please log in</ng-template>

<!-- After -->
@if (isLoggedIn) {
  <div>Welcome</div>
} @else {
  <div>Please log in</div>
}
<!-- ============================================
PART 3: *ngFor TO @for
============================================ -->
<!-- Before -->
<ul>
  <li *ngFor="let item of items; trackBy: trackById">{{ item.name }}</li>
</ul>

<!-- After -->
<ul>
  @for (item of items; track item.id) {
    <li>{{ item.name }}</li>
  }
</ul>
<!-- ============================================
PART 4: @for WITH @empty
============================================ -->
<ul>
  @for (item of items; track item.id) {
    <li>{{ item.name }}</li>
  } @empty {
    <li>No items found</li>
  }
</ul>
<!-- ============================================
PART 5: @for WITH CONTEXTUAL VARIABLES
============================================ -->
@for (item of items; track item.id; let idx = $index, isFirst = $first) {
  <p>Item {{ idx }} (first: {{ isFirst }}): {{ item.name }}</p>
}
<!-- ============================================
PART 6: *ngSwitch TO @switch
============================================ -->
<!-- Before -->
<div [ngSwitch]="status">
  <span *ngSwitchCase="'active'">Active</span>
  <span *ngSwitchCase="'inactive'">Inactive</span>
  <span *ngSwitchDefault>Unknown</span>
</div>

<!-- After -->
@switch (status) {
  @case ('active') {
    <span>Active</span>
  }
  @case ('inactive') {
    <span>Inactive</span>
  }
  @default {
    <span>Unknown</span>
  }
}
# ============================================
PART 7: RUN THE MIGRATION SCHEMATIC
============================================ #
ng generate @angular/core:control-flow
# Prompts for path and formatting
# Migrates all matching templates
# ============================================
PART 8: SCOPE THE MIGRATION
============================================ #
ng generate @angular/core:control-flow --path=src/app/features
# Migrates only the specified directory
<!-- ============================================
PART 9: REVIEW AND FIX TRACK EXPRESSIONS
============================================ -->
<!-- The schematic may generate: -->
@for (item of items; track item) { }

<!-- Fix manually to: -->
@for (item of items; track item.id) { }
<!-- ============================================
PART 10: HANDLE THE UNMIGRATABLE CASE
============================================ -->
<!-- The schematic cannot migrate this: -->
<!-- <div [ngSwitch]="option">
  <ng-container *ngFor="let element of coll">
    <div *ngSwitchCase="element">...</div>
  </ng-container>
</div> -->

<!-- Rewrite as: -->
@for (element of coll; track element) {
  @if (option === element) {
    <div>...</div>
  }
}

These ten parts cover *ngIf to @if, *ngIf with else to @if/@else, *ngFor to @for, @empty, contextual variables, *ngSwitch to @switch, running the migration schematic, scoping the migration, reviewing track expressions, and handling the unmigratable case.


Quick Reference

Syntax Mapping

Old SyntaxNew Syntax
*ngIf@if
*ngIf with else@if / @else
*ngFor@for
trackBytrack
(no equivalent)@empty
[ngSwitch] / *ngSwitchCase@switch / @case
*ngSwitchDefault@default

Migration Command

CommandPurpose
ng generate @angular/core:control-flowMigrate entire application
--path=src/app/featuresMigrate specific directory
--project=my-appMigrate specific project

Track Expression

OptionWhen to use
track item.idUnique identifier (preferred)
track $indexStatic collections
track itemAvoid; tracks by reference

Contextual Variables

VariableMeaning
$countNumber of items
$indexCurrent index
$firstFirst item
$lastLast item
$evenEven index
$oddOdd index

Manual Fixes

IssueFix
Suboptimal trackReplace with unique property
Missing @emptyAdd fallback block
@for under @switchRewrite as @for + @if
Escaped @ or }Verify escapes are correct

Best Practices

✅ Do This:

<!-- Use @if with @else for alternatives -->
@if (isLoggedIn) { <p>Welcome</p> } @else { <p>Log in</p> }

<!-- Use @for with a unique track expression -->
@for (item of items; track item.id) { <li>{{ item.name }}</li> }

<!-- Add @empty for empty collections -->
@for (item of items; track item.id) { } @empty { <li>No items</li> }

<!-- Run the migration schematic first -->
ng generate @angular/core:control-flow

<!-- Review generated track expressions -->
@for (item of items; track item.id) { }

❌ Don’t Do This:

<!-- Don't use the old directives in new code -->
<div *ngIf="isVisible">  <!-- ❌ use @if -->

<!-- Don't use a suboptimal track expression -->
@for (item of items; track item) { }  <!-- ❌ use item.id -->

<!-- Don't forget @empty when needed -->
@for (item of items; track item.id) { }  <!-- ❌ add @empty -->

<!-- Don't nest @for under @switch -->
@switch (x) { @for (y of z; track y) { @case (y) { } } }  <!-- ❌ -->

Common Pitfalls

PitfallWhy It HappensFix
Suboptimal trackSchematic cannot infer unique IDReview and replace with item.id
Missing @emptySchematic does not add itAdd manually when fallback needed
@for under @switch failsNot supportedRewrite as @for + @if
Escaped @ or } errorsLiteral characters not escapedVerify the migration’s escapes
CommonModule still importedNot removedRemove unnecessary imports
Nested @for variable conflictContextual variable shadowingUse let aliasing for inner loops

Real-World Examples

1. Conditional Display

@if (user) { <p>{{ user.name }}</p> }

2. Conditional with Else

@if (loading) { <spinner /> } @else { <content /> }

3. Loop with Track

@for (item of items; track item.id) { <li>{{ item.name }}</li> }

4. Loop with Empty State

@for (item of items; track item.id) { <li>{{ item.name }}</li> } @empty { <li>None</li> }

5. Switch Statement

@switch (status) {
  @case ('active') { <active /> }
  @default { <unknown /> }
}

6. Nested Loops

@for (group of groups; track group.id) {
  <h3>{{ group.name }}</h3>
  @for (item of group.items; track item.id; let idx = $index) {
    <p>{{ idx }}: {{ item.name }}</p>
  }
}

7. Migration Command

ng generate @angular/core:control-flow

8. Scoped Migration

ng generate @angular/core:control-flow --path=src/app/features

9. Fix Track Expression

<!-- Before: track item -->
@for (item of items; track item.id) { }

10. Rewrite Unmigratable Case

@for (element of coll; track element) {
  @if (option === element) { <div>...</div> }
}

Visual

Syntax Mapping

┌──────────────────────────────────────────────────────────────┐
│  OLD                          NEW                             │
│  ─────────────────────────────┼───────────────────────────── │
│  *ngIf="cond"                 │  @if (cond) { }              │
│  *ngIf="cond; else tpl"       │  @if (cond) { } @else { }    │
│  *ngFor="let x of xs"         │  @for (x of xs; track id) { }│
│  trackBy: fn                  │  track x.id                   │
│  (no empty state)             │  @empty { }                   │
│  [ngSwitch]="x"               │  @switch (x) { }              │
│  *ngSwitchCase="y"            │  @case (y) { }                │
│  *ngSwitchDefault             │  @default { }                 │
└──────────────────────────────────────────────────────────────┘

Migration Flow

┌──────────────────────────────────────────────────────────────┐
│  ng generate @angular/core:control-flow                      │
│       │                                                      │
│       ▼                                                      │
│  Ask: which path? ──▶ entire app or specific directory       │
│       │                                                      │
│       ▼                                                      │
│  Ask: reformat? ──▶ apply formatter                          │
│       │                                                      │
│       ▼                                                      │
│  Schematic migrates templates                                │
│       │                                                      │
│       ▼                                                      │
│  Review and fix:                                             │
│  - track expressions                                         │
│  - @empty blocks                                             │
│  - unmigratable cases                                        │
│       │                                                      │
│       ▼                                                      │
│  Commit                                                      │
└──────────────────────────────────────────────────────────────┘

Track Expression Decision

┌──────────────────────────────────────────────────────────────┐
│  Does the item have a unique ID?                             │
│       │                                                      │
│       ├── Yes ──▶ track item.id                              │
│       │                                                      │
│       └── No ──▶ Is the collection static?                   │
│                    │                                         │
│                    ├── Yes ──▶ track $index                  │
│                    │                                         │
│                    └── No ──▶ track item (last resort)       │
│                                (slower rendering)            │
└──────────────────────────────────────────────────────────────┘

Unmigratable Case

┌──────────────────────────────────────────────────────────────┐
│  OLD (unmigratable):                                         │
│  <div [ngSwitch]="option">                                   │
│    <ng-container *ngFor="let element of coll">               │
│      <div *ngSwitchCase="element">...</div>                  │
│    </ng-container>                                           │
│  </div>                                                      │
│                                                              │
│  NEW:                                                        │
│  @for (element of coll; track element) {                     │
│    @if (option === element) {                                │
│      <div>...</div>                                          │
│    }                                                         │
│  }                                                           │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
Control flow syntaxAvailable from Angular 17
@ifReplaces *ngIf
@forReplaces *ngFor, requires track
@switchReplaces [ngSwitch]
@emptyFallback for empty collections
@caseReplaces *ngSwitchCase
@defaultReplaces *ngSwitchDefault
Migration commandng generate @angular/core:control-flow
CommonModuleNo longer needed for control flow
Track expressionMandatory in @for
Unmigratable@for under @switch

Key takeaways:

  • Angular 17 introduced built-in control flow syntax. The @if, @for, and @switch blocks replace the structural directives and are pre-compiled by the Angular compiler for better performance and type checking.
  • Run the migration schematic to convert existing templates. The command ng generate @angular/core:control-flow migrates the entire application or a specific directory. It handles most cases automatically.
  • The track expression in @for requires manual refinement. The schematic cannot infer the unique identifier, so it may use a suboptimal expression. Review and replace with a unique property like id.
  • The @empty block is not generated by the schematic. Add it manually when the application has a fallback for empty collections.
  • The @for under @switch case cannot be migrated. A pattern that generated @case blocks from an array must be rewritten as a @for block with @if.
  • CommonModule is no longer needed for control flow. The new blocks are built into the template syntax, so the import can be removed from components that only used it for *ngIf and *ngFor.
  • The migration is incremental. The --path option scopes the migration to a specific directory, so features can be migrated one at a time.

Remember: The new control flow syntax is the most significant change to Angular templates since the framework’s release. It replaces the structural directives with blocks that are closer to JavaScript, pre-compiled for performance, and no longer require CommonModule. The migration schematic automates most of the conversion, but the track expressions need review, the @empty blocks need to be added, and some edge cases need manual rewriting. Running the schematic is the first step; the second step is to review the output and refine it. The result is templates that are more readable, faster, and better type-checked.


Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!