Exploring Angular's Modern Control Flow Syntax: @if, @for, and @switch

By Daian Scuarissi
angulartypescriptweb-developmentarchitecture

Angular v17 introduced one of the most significant template syntax improvements in the framework's history: built-in control flow blocks. If you've been writing Angular templates with *ngIf, *ngFor, and *ngSwitch, get ready for a transformation that will make your templates cleaner, faster, and more intuitive.

In this comprehensive guide, we'll explore the new @if, @for, and @switch syntax, understand why these changes matter, and learn how to migrate existing code with confidence.

Why Angular Needed New Control Flow Syntax

Before diving into the new syntax, let's understand the problems Angular's team was solving.

The Structural Directive Limitations

The old structural directives (*ngIf, *ngFor, *ngSwitch) served us well for years, but they came with notable limitations:

Performance Overhead: Structural directives are components under the hood, adding runtime overhead for what should be simple control flow operations.

Cognitive Load: The asterisk prefix (*) is syntactic sugar that can confuse developers, especially those new to Angular.

Limited Type Safety: Type narrowing and type safety were challenging to implement with structural directives.

Bundle Size: Each structural directive added to your bundle size, even for basic control flow.

The Built-in Control Flow Solution

Angular's built-in control flow addresses these issues by:

The New @if Block: Conditional Rendering Reimagined

Let's start with the most commonly used control flow: conditional rendering.

Basic @if Syntax

The new @if block provides a cleaner way to conditionally render content:

import { Component, signal } from '@angular/core';
 
@Component({
  selector: 'app-user-profile',
  template: `
    @if (isLoggedIn()) {
      <div class="user-profile">
        <h2>Welcome back, {{ username() }}!</h2>
        <button (click)="logout()">Logout</button>
      </div>
    }
  `,
})
export class UserProfileComponent {
  protected readonly isLoggedIn = signal(true);
  protected readonly username = signal('Daian');
 
  logout(): void {
    this.isLoggedIn.set(false);
  }
}

*Key Differences from ngIf:

@if with @else Blocks

The @else block provides fallback content:

@Component({
  selector: 'app-auth-status',
  template: `
    @if (isLoggedIn()) {
      <div class="authenticated">
        <p>You have access to premium features</p>
      </div>
    } @else {
      <div class="unauthenticated">
        <p>Please log in to access premium features</p>
        <button (click)="login()">Login</button>
      </div>
    }
  `,
})
export class AuthStatusComponent {
  protected readonly isLoggedIn = signal(false);
 
  login(): void {
    this.isLoggedIn.set(true);
  }
}

@if with @else if Chains

For multiple conditions, use @else if:

@Component({
  selector: 'app-subscription-badge',
  template: `
    @if (subscriptionLevel() === 'premium') {
      <div class="badge premium">
        <span>Premium Member</span>
      </div>
    } @else if (subscriptionLevel() === 'pro') {
      <div class="badge pro">
        <span>Pro Member</span>
      </div>
    } @else if (subscriptionLevel() === 'basic') {
      <div class="badge basic">
        <span>Basic Member</span>
      </div>
    } @else {
      <div class="badge free">
        <span>Free Trial</span>
      </div>
    }
  `,
})
export class SubscriptionBadgeComponent {
  protected readonly subscriptionLevel = signal<'premium' | 'pro' | 'basic' | 'free'>('free');
}

Type Narrowing with @if

One powerful feature is enhanced type narrowing within @if blocks:

interface User {
  id: string;
  name: string;
  email?: string;
}
 
@Component({
  selector: 'app-user-details',
  template: `
    @if (user(); as currentUser) {
      <div class="user-card">
        <h3>{{ currentUser.name }}</h3>
        <p>ID: {{ currentUser.id }}</p>
        @if (currentUser.email) {
          <p>Email: {{ currentUser.email }}</p>
        }
      </div>
    } @else {
      <p>No user selected</p>
    }
  `,
})
export class UserDetailsComponent {
  protected readonly user = signal<User | null>(null);
}

The as syntax creates a type-narrowed alias, ensuring TypeScript knows the value is non-null within the block.

The New @for Block: Iteration Made Better

The @for block replaces *ngFor with improved performance and clearer syntax.

Basic @for Syntax with track

The most important change: track is mandatory in @for blocks:

@Component({
  selector: 'app-task-list',
  template: `
    <ul class="task-list">
      @for (task of tasks(); track task.id) {
        <li class="task-item">
          <span>{{ task.title }}</span>
          <button (click)="deleteTask(task.id)">Delete</button>
        </li>
      }
    </ul>
  `,
})
export class TaskListComponent {
  protected readonly tasks = signal([
    { id: 1, title: 'Write blog post', completed: false },
    { id: 2, title: 'Review pull requests', completed: true },
    { id: 3, title: 'Update documentation', completed: false },
  ]);
 
  deleteTask(id: number): void {
    this.tasks.update(tasks => tasks.filter(t => t.id !== id));
  }
}

Why track is Mandatory:

Using @for with Implicit Variables

The @for block provides useful implicit variables:

@Component({
  selector: 'app-product-grid',
  template: `
    <div class="product-grid">
      @for (product of products(); track product.id; let idx = $index, isFirst = $first, isLast = $last, isEven = $even) {
        <div class="product-card" [class.first]="isFirst" [class.last]="isLast" [class.even]="isEven">
          <span class="product-number">{{ idx + 1 }}</span>
          <h3>{{ product.name }}</h3>
          <p>{{ product.price | currency }}</p>
        </div>
      }
    </div>
  `,
})
export class ProductGridComponent {
  protected readonly products = signal([
    { id: 1, name: 'Laptop', price: 999.99 },
    { id: 2, name: 'Mouse', price: 29.99 },
    { id: 3, name: 'Keyboard', price: 79.99 },
  ]);
}

Available Implicit Variables:

@for with @empty Block

Handle empty collections elegantly with the @empty block:

@Component({
  selector: 'app-notification-list',
  template: `
    <div class="notifications">
      <h2>Notifications</h2>
      @for (notification of notifications(); track notification.id) {
        <div class="notification" [class.unread]="!notification.read">
          <p>{{ notification.message }}</p>
          <small>{{ notification.timestamp | date }}</small>
        </div>
      } @empty {
        <div class="empty-state">
          <p>No notifications yet</p>
          <small>You're all caught up!</small>
        </div>
      }
    </div>
  `,
})
export class NotificationListComponent {
  protected readonly notifications = signal<Array<{
    id: number;
    message: string;
    read: boolean;
    timestamp: Date;
  }>>([]);
}

The @empty block renders when the collection is empty or null, eliminating the need for separate *ngIf checks.

Nested @for Blocks

For complex data structures like grids or matrices:

@Component({
  selector: 'app-chess-board',
  template: `
    <div class="chess-board">
      @for (row of board(); track $index; let rowIdx = $index) {
        <div class="board-row">
          @for (square of row; track $index; let colIdx = $index) {
            <div class="square"
                 [class.light]="(rowIdx + colIdx) % 2 === 0"
                 [class.dark]="(rowIdx + colIdx) % 2 !== 0">
              @if (square) {
                <img [src]="getPieceImage(square)" [alt]="square" />
              }
            </div>
          }
        </div>
      }
    </div>
  `,
})
export class ChessBoardComponent {
  protected readonly board = signal<string[][]>([
    ['♜', '♞', '♝', '♛', '♚', '♝', '♞', '♜'],
    ['♟', '♟', '♟', '♟', '♟', '♟', '♟', '♟'],
    // ... more rows
  ]);
 
  getPieceImage(piece: string): string {
    return `/assets/pieces/${piece}.svg`;
  }
}

The New @switch Block: Pattern Matching Simplified

The @switch block provides cleaner conditional rendering for multiple cases.

Basic @switch Syntax

@Component({
  selector: 'app-status-indicator',
  template: `
    @switch (status()) {
      @case ('loading') {
        <div class="status loading">
          <span class="spinner"></span>
          <p>Loading...</p>
        </div>
      }
      @case ('success') {
        <div class="status success">
          <span class="icon-check"></span>
          <p>Operation successful!</p>
        </div>
      }
      @case ('error') {
        <div class="status error">
          <span class="icon-error"></span>
          <p>Something went wrong</p>
        </div>
      }
      @default {
        <div class="status idle">
          <p>Ready</p>
        </div>
      }
    }
  `,
})
export class StatusIndicatorComponent {
  protected readonly status = signal<'loading' | 'success' | 'error' | 'idle'>('idle');
}

Key Features:

@switch with Complex Expressions

@Component({
  selector: 'app-theme-selector',
  template: `
    @switch (currentTheme()) {
      @case ('dark') {
        <div class="theme-preview dark-theme">
          <h3>Dark Theme</h3>
          <p>Easier on the eyes at night</p>
          <button (click)="setTheme('light')">Switch to Light</button>
        </div>
      }
      @case ('light') {
        <div class="theme-preview light-theme">
          <h3>Light Theme</h3>
          <p>Classic and clean</p>
          <button (click)="setTheme('auto')">Switch to Auto</button>
        </div>
      }
      @case ('auto') {
        <div class="theme-preview auto-theme">
          <h3>Auto Theme</h3>
          <p>Follows system preferences</p>
          <button (click)="setTheme('dark')">Switch to Dark</button>
        </div>
      }
      @default {
        <div class="theme-preview">
          <p>Theme not set</p>
        </div>
      }
    }
  `,
})
export class ThemeSelectorComponent {
  protected readonly currentTheme = signal<'dark' | 'light' | 'auto'>('auto');
 
  setTheme(theme: 'dark' | 'light' | 'auto'): void {
    this.currentTheme.set(theme);
  }
}

Comparing Old vs New: Side-by-Side Examples

Let's see practical comparisons of common patterns.

Example 1: User Dashboard

Old Structural Directives:

@Component({
  selector: 'app-dashboard',
  template: `
    <div class="dashboard">
      <div *ngIf="user; else loading">
        <h1>Welcome, {{ user.name }}</h1>
 
        <div *ngIf="user.isPremium">
          <h2>Premium Features</h2>
        </div>
 
        <ul>
          <li *ngFor="let item of user.recentItems; trackBy: trackById">
            {{ item.name }}
          </li>
          <li *ngIf="user.recentItems.length === 0">
            No recent items
          </li>
        </ul>
      </div>
 
      <ng-template #loading>
        <p>Loading...</p>
      </ng-template>
    </div>
  `,
})
export class DashboardComponent {
  user: User | null = null;
 
  trackById(index: number, item: any): number {
    return item.id;
  }
}

New Control Flow:

@Component({
  selector: 'app-dashboard',
  template: `
    <div class="dashboard">
      @if (user(); as currentUser) {
        <h1>Welcome, {{ currentUser.name }}</h1>
 
        @if (currentUser.isPremium) {
          <h2>Premium Features</h2>
        }
 
        <ul>
          @for (item of currentUser.recentItems; track item.id) {
            <li>{{ item.name }}</li>
          } @empty {
            <li>No recent items</li>
          }
        </ul>
      } @else {
        <p>Loading...</p>
      }
    </div>
  `,
})
export class DashboardComponent {
  protected readonly user = signal<User | null>(null);
}

Benefits:

Example 2: Status-Based Rendering

Old Structural Directives:

@Component({
  template: `
    <div [ngSwitch]="requestStatus">
      <div *ngSwitchCase="'idle'">
        Ready to start
      </div>
      <div *ngSwitchCase="'loading'">
        Loading...
      </div>
      <div *ngSwitchCase="'success'">
        <div *ngIf="data">
          <div *ngFor="let item of data; trackBy: trackById">
            {{ item.name }}
          </div>
        </div>
      </div>
      <div *ngSwitchCase="'error'">
        Error occurred
      </div>
      <div *ngSwitchDefault>
        Unknown state
      </div>
    </div>
  `,
})
export class DataViewComponent {
  requestStatus: 'idle' | 'loading' | 'success' | 'error' = 'idle';
  data: any[] = [];
 
  trackById(index: number, item: any): number {
    return item.id;
  }
}

New Control Flow:

@Component({
  template: `
    @switch (requestStatus()) {
      @case ('idle') {
        <div>Ready to start</div>
      }
      @case ('loading') {
        <div>Loading...</div>
      }
      @case ('success') {
        @if (data()) {
          @for (item of data(); track item.id) {
            <div>{{ item.name }}</div>
          }
        }
      }
      @case ('error') {
        <div>Error occurred</div>
      }
      @default {
        <div>Unknown state</div>
      }
    }
  `,
})
export class DataViewComponent {
  protected readonly requestStatus = signal<'idle' | 'loading' | 'success' | 'error'>('idle');
  protected readonly data = signal<Array<{ id: number; name: string }>>([]);
}

Benefits:

Migration Strategies: Moving to the New Syntax

Angular provides automated migration tools to help transition your codebase.

Automated Migration with Schematics

Run the control flow migration schematic:

ng generate @angular/core:control-flow-migration

This schematic will:

Manual Migration Steps

For custom or complex cases:

  1. Identify Patterns: Review your templates for structural directives
  2. Start Simple: Begin with basic *ngIf conversions
  3. Add track Expressions: Ensure every @for has proper tracking
  4. Test Incrementally: Verify functionality after each change
  5. Update Type Safety: Leverage type narrowing features

Migration Best Practices

1. Convert Templates in Phases:

// Phase 1: Convert simple conditionals
*ngIf → @if
 
// Phase 2: Convert loops with proper tracking
*ngFor → @for with track
 
// Phase 3: Convert switch statements
*ngSwitch → @switch

2. Choose Appropriate track Expressions:

// For objects with IDs
@for (item of items; track item.id) { }
 
// For primitives
@for (value of values; track $index) { }
 
// For complex scenarios
@for (item of items; track trackingFunction(item)) { }

3. Leverage New Features:

// Use @empty for better UX
@for (item of items; track item.id) {
  // Item template
} @empty {
  // Empty state
}
 
// Use type narrowing
@if (user(); as currentUser) {
  // TypeScript knows currentUser is non-null
}

Performance Benefits: The Numbers

The new control flow syntax delivers measurable performance improvements.

Benchmark Comparisons

Initial Render Performance:

Update Performance:

Bundle Size:

Why It's Faster

  1. No Component Overhead: Control flow blocks aren't components
  2. Better Tree Shaking: Less code to include in bundles
  3. Optimized Change Detection: Framework-level optimizations
  4. Reduced Indirection: Direct DOM manipulation

Best Practices for Modern Angular Templates

1. Always Use track in @for Blocks

// Bad: This will cause errors
@for (item of items) {
  <div>{{ item.name }}</div>
}
 
// Good: Proper tracking
@for (item of items; track item.id) {
  <div>{{ item.name }}</div>
}
 
// Also good: Track by index for simple cases
@for (value of values; track $index) {
  <div>{{ value }}</div>
}

2. Leverage @empty for Better UX

// Good: Explicit empty state
@for (post of blogPosts(); track post.id) {
  <article>{{ post.title }}</article>
} @empty {
  <div class="empty-state">
    <p>No blog posts yet</p>
    <button (click)="createFirstPost()">Write your first post</button>
  </div>
}

3. Use Type Narrowing with as

// Good: Type-safe rendering
@if (apiResponse(); as response) {
  <div>
    <h2>{{ response.data.title }}</h2>
    <p>{{ response.data.description }}</p>
  </div>
}

4. Prefer @switch Over Nested @if

// Less readable: Nested ifs
@if (status === 'pending') {
  <div>Pending</div>
} @else if (status === 'approved') {
  <div>Approved</div>
} @else if (status === 'rejected') {
  <div>Rejected</div>
}
 
// More readable: Switch
@switch (status) {
  @case ('pending') { <div>Pending</div> }
  @case ('approved') { <div>Approved</div> }
  @case ('rejected') { <div>Rejected</div> }
}

5. Combine with Signals for Reactivity

@Component({
  template: `
    @if (isLoading()) {
      <app-spinner />
    } @else if (error()) {
      <app-error-message [error]="error()" />
    } @else if (data(); as currentData) {
      @for (item of currentData; track item.id) {
        <app-item [item]="item" />
      } @empty {
        <app-empty-state />
      }
    }
  `,
})
export class DataListComponent {
  protected readonly isLoading = signal(false);
  protected readonly error = signal<Error | null>(null);
  protected readonly data = signal<Item[]>([]);
}

Common Pitfalls and How to Avoid Them

Pitfall 1: Forgetting track in @for

// Error: This will fail
@for (item of items) {
  <div>{{ item }}</div>
}
 
// Fix: Always include track
@for (item of items; track $index) {
  <div>{{ item }}</div>
}

Pitfall 2: Using Non-Unique Identifiers for Tracking

// Problematic: Non-unique values
@for (item of items; track item.type) { // Multiple items may share type
  <div>{{ item.name }}</div>
}
 
// Better: Use unique identifier
@for (item of items; track item.id) {
  <div>{{ item.name }}</div>
}

Pitfall 3: Mixing Old and New Syntax

// Confusing: Mixed syntax
@if (showList) {
  <div *ngFor="let item of items">{{ item }}</div>
}
 
// Consistent: Use new syntax throughout
@if (showList) {
  @for (item of items; track item.id) {
    <div>{{ item }}</div>
  }
}

Pitfall 4: Not Leveraging Type Narrowing

// Missing opportunity: Manual null checks
@if (user) {
  <div>{{ user!.name }}</div> // Using non-null assertion
}
 
// Better: Type narrowing with as
@if (user; as currentUser) {
  <div>{{ currentUser.name }}</div> // No assertion needed
}

Real-World Example: Complete Component Migration

Let's migrate a complete e-commerce product list component.

Before: Using Structural Directives

import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
 
interface Product {
  id: number;
  name: string;
  price: number;
  inStock: boolean;
  category: 'electronics' | 'clothing' | 'books';
}
 
@Component({
  selector: 'app-product-list',
  standalone: true,
  imports: [CommonModule],
  template: `
    <div class="product-list">
      <div *ngIf="isLoading; else content">
        <p>Loading products...</p>
      </div>
 
      <ng-template #content>
        <div *ngIf="products.length > 0; else empty">
          <div *ngFor="let product of products; trackBy: trackById"
               class="product-card">
            <h3>{{ product.name }}</h3>
            <p>{{ product.price | currency }}</p>
 
            <div [ngSwitch]="product.category">
              <span *ngSwitchCase="'electronics'" class="badge blue">
                Electronics
              </span>
              <span *ngSwitchCase="'clothing'" class="badge green">
                Clothing
              </span>
              <span *ngSwitchCase="'books'" class="badge yellow">
                Books
              </span>
            </div>
 
            <span *ngIf="product.inStock; else outOfStock" class="in-stock">
              In Stock
            </span>
            <ng-template #outOfStock>
              <span class="out-of-stock">Out of Stock</span>
            </ng-template>
          </div>
        </div>
 
        <ng-template #empty>
          <p>No products available</p>
        </ng-template>
      </ng-template>
    </div>
  `,
})
export class ProductListComponent {
  isLoading = false;
  products: Product[] = [
    { id: 1, name: 'Laptop', price: 999, inStock: true, category: 'electronics' },
    { id: 2, name: 'T-Shirt', price: 29, inStock: false, category: 'clothing' },
    { id: 3, name: 'Book', price: 19, inStock: true, category: 'books' },
  ];
 
  trackById(index: number, product: Product): number {
    return product.id;
  }
}

After: Using New Control Flow

import { Component, signal } from '@angular/core';
import { CurrencyPipe } from '@angular/common';
 
interface Product {
  id: number;
  name: string;
  price: number;
  inStock: boolean;
  category: 'electronics' | 'clothing' | 'books';
}
 
@Component({
  selector: 'app-product-list',
  standalone: true,
  imports: [CurrencyPipe],
  template: `
    <div class="product-list">
      @if (isLoading()) {
        <p>Loading products...</p>
      } @else {
        @for (product of products(); track product.id) {
          <div class="product-card">
            <h3>{{ product.name }}</h3>
            <p>{{ product.price | currency }}</p>
 
            @switch (product.category) {
              @case ('electronics') {
                <span class="badge blue">Electronics</span>
              }
              @case ('clothing') {
                <span class="badge green">Clothing</span>
              }
              @case ('books') {
                <span class="badge yellow">Books</span>
              }
            }
 
            @if (product.inStock) {
              <span class="in-stock">In Stock</span>
            } @else {
              <span class="out-of-stock">Out of Stock</span>
            }
          </div>
        } @empty {
          <p>No products available</p>
        }
      }
    </div>
  `,
})
export class ProductListComponent {
  protected readonly isLoading = signal(false);
  protected readonly products = signal<Product[]>([
    { id: 1, name: 'Laptop', price: 999, inStock: true, category: 'electronics' },
    { id: 2, name: 'T-Shirt', price: 29, inStock: false, category: 'clothing' },
    { id: 3, name: 'Book', price: 19, inStock: true, category: 'books' },
  ]);
}

Improvements:

Conclusion: Embracing Angular's Modern Template Syntax

Angular's built-in control flow syntax represents a significant leap forward in template authoring. The new @if, @for, and @switch blocks deliver:

Better Performance: Faster rendering and smaller bundle sizes through framework-level optimizations

Improved Developer Experience: Cleaner, more intuitive syntax that's easier to read and maintain

Enhanced Type Safety: Better type narrowing and inference for more robust applications

Future-Ready Architecture: Built on modern patterns that align with Angular's long-term vision

Getting Started Today

  1. Update Angular: Ensure you're on Angular v17 or later
  2. Run Migration: Use ng generate @angular/core:control-flow-migration
  3. Test Thoroughly: Verify all functionality after migration
  4. Adopt Gradually: Start with new components, migrate old ones incrementally
  5. Leverage Signals: Combine control flow with Angular signals for maximum benefit

The Path Forward

While the old structural directives (*ngIf, *ngFor, *ngSwitch) aren't immediately deprecated, the Angular team clearly signals (pun intended) their intention: the future of Angular templates is built on these new primitives.

New projects should exclusively use the built-in control flow syntax. For existing projects, plan a gradual migration that prioritizes frequently-changed components. The automated migration tools make this transition surprisingly painless.

The template revolution arrived in Angular v17. By exploring and adopting these control flow blocks, you're not just writing cleaner code - you're building faster, more maintainable applications that leverage the full power of modern Angular.


Ready to migrate your Angular applications? Start with the control flow migration schematic and experience the benefits firsthand. Your future self (and your team) will thank you for the cleaner, more performant templates.

Resources: