AngularJSBuilt-in Control Flow (@if, @for)

Built-in Control Flow in Angular

Angular 17 introduced built-in control flow — a new template syntax using @if, @for, and @switch blocks. These replace the older *ngIf, *ngFor, and *ngSwitch structural directives with a cleaner, more ergonomic syntax that requires no imports and offers better TypeScript type narrowing.

The new syntax was stabilised in Angular 17 and is now the recommended approach for all new code.

@if — Conditional Rendering

@if conditionally renders content based on an expression. It supports @else if and @else branches in a familiar programming-language style.

TS
@Component({
  selector: 'app-auth-status',
  standalone: true,
  template: `
    @if (user) {
      <p>Welcome, {{ user.name }}!</p>
      <button (click)="logout()">Logout</button>
    } @else if (isLoading) {
      <p>Checking authentication...</p>
    } @else {
      <p>You are not logged in.</p>
      <button (click)="login()">Login</button>
    }
  `,
})
export class AuthStatusComponent {
  user: { name: string } | null = { name: 'Alice' };
  isLoading = false;

  login()  { this.user = { name: 'Alice' }; }
  logout() { this.user = null; }
}
Note
\`@if\` provides automatic **type narrowing**. Inside the \`@if (user)\` block, TypeScript knows \`user\` is not null — unlike \`*ngIf\` which required the \`as\` trick to achieve this.
@if with Type Narrowing

One of the biggest improvements over *ngIf is that @if narrows the type inside the block, enabling type-safe template expressions.

TS
interface Admin { name: string; permissions: string[] }
interface Viewer { name: string; readOnly: true }

type UserRole = Admin | Viewer;

@Component({
  selector: 'app-role-panel',
  standalone: true,
  template: `
    @if (isAdmin(user)) {
      <!-- TypeScript knows user is Admin here -->
      <ul>
        @for (perm of user.permissions; track perm) {
          <li>{{ perm }}</li>
        }
      </ul>
    } @else {
      <!-- TypeScript knows user is Viewer here -->
      <p>Read-only access</p>
    }
  `,
})
export class RolePanelComponent {
  user: UserRole = { name: 'Bob', permissions: ['read', 'write'] };

  isAdmin(u: UserRole): u is Admin {
    return 'permissions' in u;
  }
}
@for — List Rendering

@for iterates over an iterable and renders the block for each item. The track expression is required — it tells Angular how to identify items for efficient DOM updates.

TS
@Component({
  selector: 'app-task-list',
  standalone: true,
  template: `
    <ul>
      @for (task of tasks; track task.id) {
        <li [class.done]="task.done">
          <input type="checkbox" [checked]="task.done" (change)="toggle(task)" />
          {{ task.title }}
        </li>
      }
    </ul>
  `,
})
export class TaskListComponent {
  tasks = [
    { id: 1, title: 'Learn @for syntax',  done: true  },
    { id: 2, title: 'Build a component',  done: false },
    { id: 3, title: 'Write tests',        done: false },
  ];

  toggle(task: { id: number; title: string; done: boolean }) {
    task.done = !task.done;
  }
}
Warning
The \`track\` expression is mandatory in \`@for\` (unlike \`*ngFor\` where \`trackBy\` was optional). Use a unique identifier like \`task.id\`. Using \`track $index\` works but is discouraged for lists that reorder.
@for Local Variables

@for exposes context variables using the let keyword. These give you index, count, and position information.

HTML
@for (item of items; track item.id; let i = $index, let c = $count, let f = $first, let l = $last, let e = $even, let o = $odd) {
  <div [class.striped]="e" [class.first]="f">
    {{ i + 1 }} / {{ c }}: {{ item.name }}
    @if (l) { (last item) }
  </div>
}

Variable

Type

Description

$index

number

Zero-based position in the collection

$count

number

Total number of items

$first

boolean

True for the first item

$last

boolean

True for the last item

$even

boolean

True for even-indexed items (0, 2, 4...)

$odd

boolean

True for odd-indexed items (1, 3, 5...)

@for @empty — Handling Empty Collections

The @empty block is a first-class citizen in @for. It renders when the collection is empty or null/undefined.

TS
@Component({
  selector: 'app-search-results',
  standalone: true,
  template: `
    <div class="results">
      @for (result of results; track result.id) {
        <div class="result-card">
          <h3>{{ result.title }}</h3>
          <p>{{ result.description }}</p>
        </div>
      } @empty {
        <div class="empty-state">
          <p>No results found for "{{ query }}"</p>
          <button (click)="clearSearch()">Clear search</button>
        </div>
      }
    </div>
  `,
})
export class SearchResultsComponent {
  query = 'Angular';
  results: { id: number; title: string; description: string }[] = [];

  clearSearch() {
    this.query = '';
  }
}
Tip
Before \`@for @empty\`, developers needed an \`*ngIf\` outside the loop to show an empty state. The built-in \`@empty\` block is cleaner and keeps the logic together.
@switch — Multi-Branch Rendering

@switch evaluates an expression and renders the matching @case block. Use @default as a catch-all fallback.

TS
@Component({
  selector: 'app-user-role',
  standalone: true,
  template: `
    @switch (role) {
      @case ('admin') {
        <div class="panel admin-panel">
          <h2>Admin Dashboard</h2>
          <p>Full system access</p>
        </div>
      }
      @case ('editor') {
        <div class="panel editor-panel">
          <h2>Editor Workspace</h2>
          <p>Create and edit content</p>
        </div>
      }
      @case ('viewer') {
        <div class="panel viewer-panel">
          <h2>Read-Only View</h2>
          <p>Browse published content</p>
        </div>
      }
      @default {
        <div class="panel error-panel">
          <p>Unknown role: {{ role }}</p>
        </div>
      }
    }

    <select [(ngModel)]="role">
      <option>admin</option>
      <option>editor</option>
      <option>viewer</option>
    </select>
  `,
})
export class UserRoleComponent {
  role = 'admin';
}
Nesting Control Flow

Control flow blocks can be nested freely. Use this to build complex conditional list rendering.

HTML
@if (isLoading) {
  <app-spinner />
} @else if (error) {
  <app-error [message]="error" />
} @else {
  <section>
    @for (category of categories; track category.id) {
      <div class="category">
        <h2>{{ category.name }}</h2>

        @if (category.products.length > 0) {
          <ul>
            @for (product of category.products; track product.id; let i = $index) {
              <li>
                {{ i + 1 }}. {{ product.name }}
                @switch (product.status) {
                  @case ('new')  { <span class="badge new">New</span> }
                  @case ('sale') { <span class="badge sale">Sale</span> }
                }
              </li>
            }
          </ul>
        } @else {
          <p>No products in this category yet.</p>
        }
      </div>
    }
  </section>
}
Migration from Structural Directives

Angular provides an official migration schematic to automatically convert your *ngIf/*ngFor/*ngSwitch usages to the new syntax.

Bash
# Migrate the entire project
ng generate @angular/core:control-flow

# Migrate a specific directory
ng generate @angular/core:control-flow --path src/app/features
UPDATE src/app/app.component.html (120 bytes)
UPDATE src/app/features/product-list/product-list.component.html (340 bytes)
UPDATE src/app/features/cart/cart.component.html (215 bytes)
Old vs New Comparison

Old Directive

New Block

Key Difference

*ngIf="x"

@if (x) { }

No imports, @else if built-in, type narrowing

*ngIf="x; else tmpl"

@if (x) { } @else { }

Inline @else, no ng-template reference

*ngFor="let i of items; trackBy: fn"

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

track is required, @empty block built-in

[ngSwitch] + *ngSwitchCase

@switch (x) { @case (v) { } }

Single block syntax, @default keyword

Summary
  • @if / @else if / @else replaces *ngIf with automatic type narrowing and no imports needed.

  • @for (item of list; track item.id) replaces *ngFor — track is now required.

  • @empty inside @for handles empty collections without a separate *ngIf.

  • $index, $count, $first, $last, $even, $odd are available as let variables.

  • @switch / @case / @default replaces [ngSwitch] / *ngSwitchCase.

  • Control flow blocks can be freely nested.

  • Use the Angular CLI schematic ng generate @angular/core:control-flow to auto-migrate.