Angular’s built-in directives extend HTML’s vocabulary — they add conditional rendering, list iteration, multi-branch logic, and dynamic styling directly to your templates. These directives are the tools you will use in virtually every template you write. Understanding how each one works, their performance implications, and their common pitfalls is the key to writing Angular templates that are both expressive and efficient. Angular 17 also introduced the new control flow syntax (@if, @for, @switch) as a more powerful replacement for the older structural directives.
Structural Directives vs Attribute Directives
| Type | What They Do | Examples |
|---|---|---|
| Structural | Add, remove, or repeat DOM elements — change the DOM structure | *ngIf, *ngFor, *ngSwitch, @if, @for, @switch |
| Attribute | Change the appearance or behaviour of existing elements | [ngClass], [ngStyle], [ngModel] |
ngFor / @for — Rendering Lists
| Feature | Syntax | Example |
|---|---|---|
| Basic iteration | *ngFor="let item of items" |
Render each task |
| Index | *ngFor="let item of items; let i = index" |
Show row numbers |
| First / last | let isFirst = first; let isLast = last |
Style first/last item differently |
| Even / odd | let isEven = even |
Alternating row colours |
| Track by (performance) | trackBy: trackByFn |
Prevent full list re-render on change |
| New syntax | @for (item of items; track item.id) |
Angular 17+ — track is required |
@if, @for, and @switch. This replaces *ngIf, *ngFor, and *ngSwitch and no longer requires importing CommonModule. The new syntax is compiled into more optimised code, requires track on @for (equivalent to trackBy), supports @empty blocks for empty collections, and uses @else if chains rather than nesting. New Angular 17+ projects should prefer the new syntax; both work in the same codebase.trackBy (or the required track in new syntax) with *ngFor / @for when rendering lists of objects that can change. Without tracking, Angular destroys and recreates every DOM element in the list whenever the array changes — even if only one item was added or removed. With trackBy: trackById, Angular identifies which items changed and only updates those DOM nodes, which is dramatically faster for large or frequently-updated lists.*ngIf (or @if) on the same element as *ngFor (or @for) is not allowed — only one structural directive per element. The correct pattern is to wrap the element with an <ng-container> (which renders no actual DOM element) and apply the *ngIf to the container and *ngFor to the inner element — or use the new control flow syntax which has no such limitation.Complete Directives Reference with Examples
<!-- ── *ngIf / @if — conditional rendering ──────────────────────────── -->
<!-- Legacy syntax -->
<div *ngIf="tasks.length > 0">Task list here</div>
<div *ngIf="isLoading; else loaded">Loading...</div>
<ng-template #loaded><div>Content loaded</div></ng-template>
<!-- New syntax (Angular 17+) -->
@if (isLoading) {
<app-spinner></app-spinner>
} @else if (error) {
<p class="error">{{ error }}</p>
} @else {
<app-task-list [tasks]="tasks"></app-task-list>
}
<!-- ── *ngFor / @for — list rendering ────────────────────────────────── -->
<!-- Legacy syntax -->
<li *ngFor="let task of tasks; let i = index; trackBy: trackById"
[class.first]="i === 0">
{{ i + 1 }}. {{ task.title }}
</li>
<!-- New syntax (Angular 17+) — track is REQUIRED -->
@for (task of tasks(); track task._id) {
<app-task-card [task]="task" (completed)="onComplete($event)"></app-task-card>
} @empty {
<p>No tasks found. Create your first task!</p>
}
<!-- with index and other variables -->
@for (task of tasks(); track task._id; let i = $index, isFirst = $first, isLast = $last) {
<li [class.first]="isFirst" [class.last]="isLast">
{{ i + 1 }}. {{ task.title }}
</li>
}
<!-- ── *ngSwitch / @switch — multi-branch rendering ─────────────────── -->
<!-- Legacy syntax -->
<div [ngSwitch]="task.status">
<span *ngSwitchCase="'pending'">⏳ Pending</span>
<span *ngSwitchCase="'in-progress'">▶ In Progress</span>
<span *ngSwitchCase="'completed'">✅ Completed</span>
<span *ngSwitchDefault>Unknown</span>
</div>
<!-- New syntax (Angular 17+) -->
@switch (task.status) {
@case ('pending') { <span class="badge badge--pending">Pending</span> }
@case ('in-progress') { <span class="badge badge--progress">In Progress</span> }
@case ('completed') { <span class="badge badge--done">Completed</span> }
@default { <span>Unknown status</span> }
}
<!-- ── [ngClass] — dynamic CSS classes ──────────────────────────────── -->
<!-- Toggle single class -->
<div [class.overdue]="task.isOverdue">{{ task.title }}</div>
<!-- Object syntax — key=class name, value=condition -->
<div [ngClass]="{
'task--high': task.priority === 'high',
'task--completed': task.status === 'completed',
'task--overdue': task.isOverdue,
'task--selected': isSelected
}">
{{ task.title }}
</div>
<!-- Array syntax — combine static and dynamic classes -->
<div [ngClass]="['task-card', 'task-card--' + task.priority, task.status === 'completed' ? 'done' : '']">
</div>
<!-- ── [ngStyle] — dynamic inline styles ─────────────────────────────── -->
<!-- Object syntax -->
<div [ngStyle]="{
'opacity': task.status === 'completed' ? '0.6' : '1',
'background-color': task.priority === 'high' ? '#fee2e2' : 'white',
'border-left': '4px solid ' + priorityColor(task.priority)
}">
{{ task.title }}
</div>
<!-- ── ng-container — grouping without extra DOM element ─────────────── -->
<!-- Problem: cannot use *ngIf and *ngFor on the same element -->
<!-- Wrong: -->
<!-- <li *ngFor="..." *ngIf="..."> -- syntax error! -->
<!-- Correct: use ng-container -->
<ng-container *ngIf="tasks.length > 0">
<li *ngFor="let task of tasks; trackBy: trackById">
{{ task.title }}
</li>
</ng-container>
<!-- ng-template — define template fragments for reuse -->
<ng-template #loadingTpl>
<div class="spinner">Loading...</div>
</ng-template>
<ng-template #errorTpl let-message="message">
<p class="error">{{ message }}</p>
</ng-template>
<ng-container *ngIf="isLoading; else contentArea">
<ng-container *ngTemplateOutlet="loadingTpl"></ng-container>
</ng-container>
<ng-template #contentArea>
<!-- actual content -->
</ng-template>
How It Works
Step 1 — Structural Directives Manipulate the DOM Tree
Angular compiles *ngIf="condition" into a template reference and a call to the directive’s ViewContainerRef. When condition is true, Angular instantiates the template and inserts it into the DOM. When false, it removes those DOM nodes entirely. This is fundamentally different from CSS display: none — with *ngIf, components inside the removed block are destroyed and their resources are freed. Use *ngIf to control existence; use [hidden] or CSS to control visibility without destroying.
Step 2 — trackBy Prevents Unnecessary DOM Destruction
Without trackBy, Angular uses object identity to compare list items. When an HTTP response returns a new array (even with the same data), every item is a new JavaScript object — Angular sees all items as different and destroys and recreates every DOM node. With trackBy: (i, task) => task._id, Angular uses the ID to identify items. Items with the same ID are reused; only genuinely new items are created and removed items are destroyed.
Step 3 — ngClass and ngStyle Are Attribute Directives
Unlike structural directives that change the DOM structure, [ngClass] and [ngStyle] modify the properties of existing elements. They accept objects where keys are class names or style properties and values are boolean conditions (ngClass) or string values (ngStyle). Angular re-evaluates the object during change detection and updates only the changed classes or styles, making them efficient for dynamic styling.
Step 4 — ng-container Is a Logical Grouping Element
<ng-container> is an Angular template element that groups other elements or applies directives without adding any DOM element itself. It renders no actual HTML — it is invisible in the browser’s inspector. This is the correct solution when you need to apply a structural directive to multiple elements without wrapping them in a <div>, or when you need to apply both *ngIf and *ngFor to the same logical group.
Step 5 — New Control Flow Has Better Type Narrowing
The new @if / @for / @switch syntax is processed by the Angular template compiler as first-class control flow rather than directives. This gives the compiler more information: inside an @if (user !== null) block, TypeScript knows user is not null — enabling type narrowing. The @empty block on @for is a natural way to handle empty collections without an extra *ngIf="items.length === 0" check. The track expression is required (not optional like trackBy), preventing performance pitfalls.
Common Mistakes
Mistake 1 — Two structural directives on the same element
❌ Wrong — Angular syntax error:
<li *ngFor="let task of tasks" *ngIf="task.status === 'pending'">
<!-- Error: Can't have multiple template bindings on one element -->
✅ Correct — use ng-container to separate them:
<ng-container *ngFor="let task of tasks; trackBy: trackById">
<li *ngIf="task.status === 'pending'">{{ task.title }}</li>
</ng-container>
<!-- OR use new syntax which has no restriction: -->
@for (task of tasks(); track task._id) {
@if (task.status === 'pending') { <li>{{ task.title }}</li> }
}
Mistake 2 — Not using trackBy — slow list updates
❌ Wrong — full list re-render on every API refresh:
<li *ngFor="let task of tasks">{{ task.title }}</li>
<!-- On refresh: all DOM nodes destroyed and recreated even if only 1 task changed -->
✅ Correct — provide a stable identity function:
<li *ngFor="let task of tasks; trackBy: trackById">{{ task.title }}</li>
<!-- In class: trackById = (i: number, task: Task) => task._id -->
Mistake 3 — Using hidden instead of ngIf for complex components
❌ Can be wrong — component remains active consuming resources:
<app-data-table [hidden]="!showTable"></app-data-table>
<!-- Component is created and its ngOnInit runs even when hidden -->
<!-- Subscriptions, timers, and API calls still run -->
✅ Correct for expensive components — use ngIf or @if to destroy when not needed:
@if (showTable) {
<app-data-table></app-data-table>
}
<!-- Component destroyed when showTable is false — resources freed -->
Quick Reference
| Directive | Legacy Syntax | New Syntax (Angular 17+) |
|---|---|---|
| Conditional render | *ngIf="condition" |
@if (condition) { } |
| Else branch | *ngIf="c; else t" |
@if (c) { } @else { } |
| Else-if | Nested *ngIf |
@else if (c2) { } |
| List iteration | *ngFor="let x of arr; trackBy: fn" |
@for (x of arr; track x.id) { } |
| Empty state | Extra *ngIf="arr.length === 0" |
@empty { } |
| Multi-branch | [ngSwitch]="val" + *ngSwitchCase |
@switch (val) { @case ('x') { } } |
| CSS class toggle | [ngClass]="{ 'cls': bool }" |
Same — no change |
| Inline styles | [ngStyle]="{ color: 'red' }" |
Same — no change |
| No-DOM wrapper | <ng-container> |
Still used with new syntax |