Skip to content
Angular ng directives 4 min read

Legacy Structural Directives

For years, *ngIf, *ngFor, and *ngSwitch were how every Angular template conditionally rendered or repeated DOM. They are structural directives: the leading asterisk is sugar for an <ng-template> that Angular adds or removes from the DOM. They still work in modern Angular, but as of v17 the built-in @if/@for/@switch control flow is the recommended replacement. Understanding the legacy syntax matters because you will meet it in nearly every existing codebase and you will need to migrate it.

How the asterisk works

The asterisk is purely syntactic. When Angular sees *ngIf="cond" on an element, it desugars it into a wrapping <ng-template> bound to the directive. These two snippets are identical:

<!-- Sugared form -->
<p *ngIf="user">Hello {{ user.name }}</p>

<!-- Desugared equivalent -->
<ng-template [ngIf]="user">
  <p>Hello {{ user.name }}</p>
</ng-template>

Because the content lives inside a template, it is not merely hidden with CSS — it is genuinely absent from the DOM when the condition is false. That distinction matters for lifecycle hooks, focus, and performance.

Legacy structural directives ship from CommonModule. In a standalone component you must add the relevant imports (NgIf, NgFor, NgSwitch, or simply CommonModule) to the component’s imports array, or the template will fail to compile.

*ngIf with else and as

*ngIf supports an else branch and an as alias that captures the bound value — handy when unwrapping an async result exactly once.

import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';

@Component({
  selector: 'app-profile',
  standalone: true,
  imports: [CommonModule],
  template: `
    <div *ngIf="user as u; else loading">
      <h2>{{ u.name }}</h2>
      <p>{{ u.email }}</p>
    </div>
    <ng-template #loading>
      <p>Loading profile…</p>
    </ng-template>
  `,
})
export class ProfileComponent {
  user: { name: string; email: string } | null = null;
}

*ngFor, trackBy, and context variables

*ngFor iterates over an iterable and exposes context variables. Always pair it with trackBy so Angular re-uses DOM nodes instead of destroying and recreating them when the array changes by reference.

import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';

interface Todo { id: number; title: string; done: boolean; }

@Component({
  selector: 'app-todos',
  standalone: true,
  imports: [CommonModule],
  template: `
    <ul>
      <li *ngFor="let t of todos; let i = index; trackBy: trackById">
        {{ i + 1 }}. {{ t.title }}
      </li>
    </ul>
  `,
})
export class TodosComponent {
  todos: Todo[] = [
    { id: 1, title: 'Write docs', done: false },
    { id: 2, title: 'Ship release', done: false },
  ];

  trackById(_index: number, item: Todo): number {
    return item.id;
  }
}

The available context variables are:

VariableMeaning
indexZero-based position in the iterable
countTotal number of items
firsttrue for the first item
lasttrue for the last item
eventrue when index is even
oddtrue when index is odd

*ngSwitch

*ngSwitch selects one of several branches. The container holds [ngSwitch] and each child uses *ngSwitchCase or *ngSwitchDefault.

<div [ngSwitch]="status">
  <span *ngSwitchCase="'active'">Active</span>
  <span *ngSwitchCase="'paused'">Paused</span>
  <span *ngSwitchDefault>Unknown</span>
</div>

The async pattern

The async pipe subscribes to an Observable or Promise, renders the latest value, and unsubscribes automatically on destroy. Combined with *ngIf ... as, it avoids manual subscription management.

import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';

@Component({
  selector: 'app-users',
  standalone: true,
  imports: [CommonModule],
  template: `
    <ng-container *ngIf="users$ | async as users; else spinner">
      <p *ngFor="let u of users; trackBy: byId">{{ u.name }}</p>
    </ng-container>
    <ng-template #spinner><p>Loading…</p></ng-template>
  `,
})
export class UsersComponent {
  private http = inject(HttpClient);
  users$: Observable<{ id: number; name: string }[]> =
    this.http.get<{ id: number; name: string }[]>('/api/users');

  byId(_i: number, u: { id: number }) { return u.id; }
}

Migrating to the new control flow

Modern Angular replaces these directives with block syntax that needs no imports, is faster, and has built-in tracking and empty states. The official schematic rewrites templates automatically:

ng generate @angular/core:control-flow

Output:

> Which path in your project should be migrated? (./src)
  ✔ Migrating templates…
  UPDATE src/app/todos.component.ts (412 bytes)
  Successfully migrated 12 files.

The equivalents map cleanly:

LegacyNew control flow
*ngIf="x" / else tpl@if (x) { … } @else { … }
*ngFor="let i of xs; trackBy: f"@for (i of xs; track i.id) { … } @empty {…}
[ngSwitch] + *ngSwitchCase@switch (x) { @case ('a') { … } @default { … } }
@if (user; as u) {
  <h2>{{ u.name }}</h2>
} @else {
  <p>Loading…</p>
}

@for (t of todos; track t.id) {
  <li>{{ t.title }}</li>
} @empty {
  <li>No todos yet</li>
}

In @for, track is mandatory — it is the direct successor to trackBy and the compiler will error without it. This makes the previously optional performance optimisation a first-class requirement.

Best practices

  • Add trackBy to every legacy *ngFor, or migrate to @for where track is enforced for you.
  • Prefer the async pipe over manual .subscribe() to prevent memory leaks; combine it with *ngIf ... as to unwrap once.
  • Use <ng-container> to host a structural directive without emitting a wrapper element.
  • Never stack two structural directives on one element — Angular forbids it; nest them or use <ng-container>.
  • Run the control-flow schematic on existing code rather than rewriting templates by hand.
  • Reserve *ngSwitch for genuinely mutually exclusive branches; a single @if/@else if chain is often clearer.
Last updated June 14, 2026
Was this helpful?