Angular ngIf, ngFor, ngSwitch and trackBy: Legacy Syntax and Migration

Angular Tutorial: Beginner to Advanced · 4. Control flow and lists · 4 min read · Updated

Before Angular 17, templates used directives with a star: *ngIf, *ngFor and [ngSwitch]. Millions of lines of code still use them, and many tutorials teach them. In this lesson you learn to read them, and how to move to the modern @if, @for and @switch blocks from the earlier lessons.

What you will learn

  • The legacy syntax side by side with the new blocks
  • What trackBy was and what replaced it
  • How to migrate automatically

Angular marks NgIf and NgFor as deprecated since version 20, and recommends the new control flow. Use the old syntax only to maintain existing code.

ngIf

// src/app/legacy/legacy-if.ts
import { Component } from '@angular/core';
import { NgIf } from '@angular/common';

@Component({
  selector: 'app-legacy-if',
  imports: [NgIf],
  template: `
    <!-- Old -->
    <p *ngIf="loggedIn; else guest">Welcome back!</p>
    <ng-template #guest><p>Please sign in.</p></ng-template>

    <!-- New -->
    @if (loggedIn) {
      <p>Welcome back!</p>
    } @else {
      <p>Please sign in.</p>
    }
  `,
})
export class LegacyIf {
  protected loggedIn = false;
}

The star *ngIf is shorthand for wrapping the element in an <ng-template>. That is why else needed a separate named template. The @if block needs neither.

ngFor and trackBy

// src/app/legacy/legacy-for.ts
import { Component } from '@angular/core';
import { NgFor } from '@angular/common';

interface Item {
  id: number;
  name: string;
}

@Component({
  selector: 'app-legacy-for',
  imports: [NgFor],
  template: `
    <!-- Old -->
    <ul>
      <li *ngFor="let item of items; let i = index; trackBy: trackById">{{ i + 1 }}. {{ item.name }}</li>
    </ul>

    <!-- New -->
    <ul>
      @for (item of items; track item.id; let i = $index) {
        <li>{{ i + 1 }}. {{ item.name }}</li>
      } @empty {
        <li>No items</li>
      }
    </ul>
  `,
})
export class LegacyFor {
  protected items: Item[] = [
    { id: 1, name: 'Apple' },
    { id: 2, name: 'Banana' },
  ];

  // Old: a function that tells Angular how to identify an item
  protected trackById(index: number, item: Item): number {
    return item.id;
  }
}

Why trackBy exists. When the list changes, Angular must decide which DOM elements to keep, move, add or remove. Without a key it may destroy and recreate every row, which is slow and loses things such as focus and typed text. trackBy returned a unique id for each item. In the new syntax, track item.id is required, so you cannot forget it. Use a stable id, not the array index, unless the list never reorders.

Old context variables map to new ones: index is $index, first is $first, last is $last, even is $even, odd is $odd, count is $count.

ngSwitch

// src/app/legacy/legacy-switch.ts
import { Component } from '@angular/core';
import { NgSwitch, NgSwitchCase, NgSwitchDefault } from '@angular/common';

@Component({
  selector: 'app-legacy-switch',
  imports: [NgSwitch, NgSwitchCase, NgSwitchDefault],
  template: `
    <!-- Old -->
    <div [ngSwitch]="role">
      <p *ngSwitchCase="'admin'">Admin panel</p>
      <p *ngSwitchCase="'editor'">Editor tools</p>
      <p *ngSwitchDefault>Read only</p>
    </div>

    <!-- New -->
    @switch (role) {
      @case ('admin') { <p>Admin panel</p> }
      @case ('editor') { <p>Editor tools</p> }
      @default { <p>Read only</p> }
    }
  `,
})
export class LegacySwitch {
  protected role = 'editor';
}

Put the three on a page

// src/app/app.ts
import { Component } from '@angular/core';
import { LegacyIf } from './legacy/legacy-if';
import { LegacyFor } from './legacy/legacy-for';
import { LegacySwitch } from './legacy/legacy-switch';

@Component({
  imports: [LegacyIf, LegacyFor, LegacySwitch],
  selector: 'app-root',
  templateUrl: './app.html',
  styleUrl: './app.css',
})
export class App {}
<!-- src/app/app.html -->
<main>
  <app-legacy-if />
  <app-legacy-for />
  <app-legacy-switch />
</main>

The page shows both syntaxes together, so you can compare them directly.

Migrate automatically

Angular has a schematic that rewrites templates for you:

ng generate @angular/core:control-flow

It converts *ngIf, *ngFor and ngSwitch to the new blocks. Review the changes in Git, run your tests, and then remove the now unused NgIf, NgFor imports (the cleanup-unused-imports schematic helps). Run migrations on a clean Git branch.

Quick comparison

  • ***ngIf="x; else y"**: Modern: @if (x) { } @else { }
  • ***ngFor="let i of list; trackBy: fn"**: Modern: @for (i of list; track i.id) { }
  • **[ngSwitch] with *ngSwitchCase**: Modern: @switch with @case
  • Import `NgIf`, `NgFor`: Modern: No imports needed

Try it yourself

  1. Convert an *ngFor with let odd = odd into @for using $odd.
  2. Run the control-flow migration on a copy of your Task Manager project and read the diff.
  3. Find a list where the id changes between loads and think about what track should be.

Quick check

  1. What does the * in *ngIf stand for?
  2. What did trackBy do?
  3. Which command migrates templates to the new control flow?

Next, continue with Angular ngClass, ngStyle, Class Binding and Style Binding Explained.

Frequently asked questions

Should I rewrite my old project right away?

Not necessarily. Old syntax still works. Migrate gradually, starting with files you are already changing.

Is ngFor faster or slower than @for?

The new @for is built into the compiler and is generally faster and lighter. The main practical gain, though, is safer, simpler templates.