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 simplyCommonModule) to the component’simportsarray, 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:
| Variable | Meaning |
|---|---|
index | Zero-based position in the iterable |
count | Total number of items |
first | true for the first item |
last | true for the last item |
even | true when index is even |
odd | true 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:
| Legacy | New 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,trackis mandatory — it is the direct successor totrackByand the compiler will error without it. This makes the previously optional performance optimisation a first-class requirement.
Best practices
- Add
trackByto every legacy*ngFor, or migrate to@forwheretrackis enforced for you. - Prefer the
asyncpipe over manual.subscribe()to prevent memory leaks; combine it with*ngIf ... asto 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-flowschematic on existing code rather than rewriting templates by hand. - Reserve
*ngSwitchfor genuinely mutually exclusive branches; a single@if/@else ifchain is often clearer.