Angular 20 Control Flow Skill
Rules
Control Flow Syntax
-
Use @if / @else / @else if for conditional rendering
-
Use @for with mandatory track expression for list iteration
-
Use @switch / @case / @default for multi-branch conditionals
-
Use @defer for lazy loading and code splitting
-
MUST NOT use structural directives: *ngIf , *ngFor , *ngSwitch
@for Track Expression
-
Every @for loop MUST include a track expression
-
Track by unique ID: track item.id
-
Track by index for static lists: track $index
-
MUST NOT track by object reference
@defer Loading States
-
Use appropriate trigger: on viewport , on interaction , on idle , on immediate , on timer(Xs) , on hover
-
Use @loading (minimum Xms) to prevent UI flashing
-
Use @placeholder (minimum Xms) for minimum display time
Signal Integration
-
Control flow conditions MUST use signal invocation: @if (signal())
-
MUST NOT use plain properties without signal invocation
Context Variables
- Available in @for : $index , $first , $last , $even , $odd , $count
Context
Purpose
This skill provides comprehensive guidance on Angular 20's built-in control flow syntax, which introduces new template syntax (@if, @for, @switch, @defer) that replaces structural directives with better performance, type safety, and developer experience.
What is Angular Control Flow?
Angular 20 introduces new built-in control flow syntax:
-
@if / @else: Conditional rendering (replaces *ngIf)
-
@for: List iteration with tracking (replaces *ngFor)
-
@switch / @case: Multi-branch conditionals (replaces *ngSwitch)
-
@defer: Lazy loading and code splitting (new feature)
-
@empty: Fallback for empty collections
-
@placeholder / @loading / @error: Defer states
When to Use This Skill
Use Angular 20 Control Flow when:
-
Writing templates with conditional rendering
-
Iterating over lists or arrays
-
Implementing switch/case logic in templates
-
Lazy loading components or content blocks
-
Handling loading states and error boundaries
-
Optimizing bundle size with deferred loading
-
Migrating from *ngIf, *ngFor, *ngSwitch to modern syntax
Core Control Flow Blocks
- @if - Conditional Rendering
Basic Usage:
@Component({
template: @if (isLoggedIn()) { <div>Welcome back, {{ username() }}!</div> }
})
export class WelcomeComponent {
isLoggedIn = signal(false);
username = signal('User');
}
@if with @else:
@Component({
template: @if (user()) { <app-dashboard [user]="user()" /> } @else { <app-login /> }
})
export class AppComponent {
user = signal<User | null>(null);
}
@if with @else if:
@Component({
template: @if (status() === 'loading') { <app-spinner /> } @else if (status() === 'error') { <app-error [message]="errorMessage()" /> } @else if (status() === 'success') { <app-content [data]="data()" /> } @else { <app-empty-state /> }
})
export class DataComponent {
status = signal<'loading' | 'error' | 'success' | 'idle'>('idle');
errorMessage = signal('');
data = signal<any[]>([]);
}
Type Narrowing:
@Component({
template: @if (item(); as currentItem) { <!-- currentItem is type-narrowed here --> <div>{{ currentItem.name }}</div> <div>{{ currentItem.description }}</div> }
})
export class ItemComponent {
item = signal<Item | null>(null);
}
- @for - List Iteration
Basic @for Loop:
@Component({
template: <ul> @for (item of items(); track item.id) { <li>{{ item.name }}</li> } </ul>
})
export class ListComponent {
items = signal([
{ id: 1, name: 'Item 1' },
{ id: 2, name: 'Item 2' },
{ id: 3, name: 'Item 3' }
]);
}
@for with Index and Context:
@Component({
template: <div class="items"> @for (item of items(); track item.id; let idx = $index, first = $first, last = $last) { <div class="item" [class.first]="first" [class.last]="last"> <span class="index">{{ idx + 1 }}.</span> <span class="name">{{ item.name }}</span> </div> } </div>
})
export class IndexedListComponent {
items = signal<Item[]>([]);
}
Available Context Variables:
-
$index
-
Current index (0-based)
-
$first
-
True if first item
-
$last
-
True if last item
-
$even
-
True if even index
-
$odd
-
True if odd index
-
$count
-
Total number of items
@for with @empty:
@Component({
template: <div class="product-list"> @for (product of products(); track product.id) { <app-product-card [product]="product" /> } @empty { <div class="empty-state"> <p>No products available</p> <button (click)="loadProducts()">Refresh</button> </div> } </div>
})
export class ProductListComponent {
products = signal<Product[]>([]);
}
Track By Best Practices:
// ✅ Good - Track by unique ID @for (user of users(); track user.id) { <app-user-card [user]="user" /> }
// ✅ Good - Track by index for static lists @for (tab of tabs; track $index) { <button>{{ tab }}</button> }
// ❌ Bad - Track by object reference (will cause unnecessary re-renders) @for (item of items(); track item) { <div>{{ item.name }}</div> }
- @switch - Multi-branch Conditionals
Basic @switch:
@Component({
template: @switch (userRole()) { @case ('admin') { <app-admin-panel /> } @case ('moderator') { <app-moderator-panel /> } @case ('user') { <app-user-panel /> } @default { <app-guest-panel /> } }
})
export class RoleBasedComponent {
userRole = signal<'admin' | 'moderator' | 'user' | 'guest'>('guest');
}
@switch with Complex Conditions:
@Component({
template: @switch (connectionStatus()) { @case ('connected') { <div class="status online"> <mat-icon>check_circle</mat-icon> Connected </div> } @case ('connecting') { <div class="status pending"> <mat-spinner diameter="20"></mat-spinner> Connecting... </div> } @case ('disconnected') { <div class="status offline"> <mat-icon>error</mat-icon> Disconnected </div> } @case ('error') { <div class="status error"> <mat-icon>warning</mat-icon> Connection Error </div> } @default { <div class="status unknown">Unknown Status</div> } }
})
export class ConnectionStatusComponent {
connectionStatus = signal<'connected' | 'connecting' | 'disconnected' | 'error'>('disconnected');
}
- @defer - Lazy Loading and Code Splitting
Basic Deferred Loading:
@Component({
template: @defer { <app-heavy-component /> } @placeholder { <div class="skeleton">Loading...</div> }
})
export class DeferredComponent {}
Defer with Loading State:
@Component({
template: @defer { <app-video-player [src]="videoUrl" /> } @loading (minimum 500ms) { <div class="loading-spinner"> <mat-spinner></mat-spinner> <p>Loading video player...</p> </div> } @placeholder { <div class="video-placeholder"> <mat-icon>play_circle</mat-icon> </div> } @error { <div class="error-state"> <p>Failed to load video player</p> <button (click)="retry()">Retry</button> </div> }
})
export class VideoComponent {
videoUrl = signal('https://example.com/video.mp4');
}
Defer Triggers:
// Viewport trigger - Load when visible @defer (on viewport) { <app-below-fold-content /> }
// Interaction trigger - Load on click @defer (on interaction) { <app-modal-content /> }
// Idle trigger - Load when browser is idle @defer (on idle) { <app-analytics-widget /> }
// Immediate trigger - Load immediately @defer (on immediate) { <app-critical-content /> }
// Timer trigger - Load after delay @defer (on timer(5s)) { <app-delayed-content /> }
// Hover trigger - Load on hover @defer (on hover) { <app-tooltip-content /> }
// Combined triggers @defer (on viewport; on idle) { <app-content /> }
Prefetching:
// Prefetch when idle @defer (on viewport; prefetch on idle) { <app-article-content /> }
// Prefetch on hover @defer (on interaction; prefetch on hover) { <app-modal /> }
Defer with Minimum Loading Time:
@Component({
template: @defer (on viewport) { <app-chart [data]="chartData()" /> } @loading (minimum 1s) { <!-- Show loading for at least 1 second to avoid flashing --> <div class="chart-skeleton"></div> } @placeholder (minimum 500ms) { <!-- Show placeholder for at least 500ms --> <div class="chart-placeholder"></div> }
})
export class ChartComponent {
chartData = signal<ChartData[]>([]);
}
Migration from Old Syntax
ngIf → @if
// Before (Angular 19 and earlier) <div *ngIf="isVisible">Content</div> <div *ngIf="user; else loading">{{ user.name }}</div>
// After (Angular 20+) @if (isVisible()) { <div>Content</div> }
@if (user(); as currentUser) { <div>{{ currentUser.name }}</div> } @else { <ng-container [ngTemplateOutlet]="loading" /> }
ngFor → @for
// Before <li *ngFor="let item of items; trackBy: trackById">{{ item.name }}</li>
// After @for (item of items(); track item.id) { <li>{{ item.name }}</li> }
ngSwitch → @switch
// Before <div [ngSwitch]="status"> <div *ngSwitchCase="'success'">Success!</div> <div *ngSwitchCase="'error'">Error!</div> <div *ngSwitchDefault>Loading...</div> </div>
// After @switch (status()) { @case ('success') { <div>Success!</div> } @case ('error') { <div>Error!</div> } @default { <div>Loading...</div> } }
Best Practices
- Use Signals with Control Flow
// ✅ Good - Reactive with signals export class Component { items = signal<Item[]>([]); isLoading = signal(false); }
@Component({
template: @if (isLoading()) { <spinner /> } @else { @for (item of items(); track item.id) { <item-card [item]="item" /> } }
})
- Always Use track in @for
// ✅ Good - Proper tracking @for (user of users(); track user.id) { <user-card [user]="user" /> }
// ❌ Bad - Missing track (will cause error) @for (user of users()) { <user-card [user]="user" /> }
- Leverage @defer for Performance
// ✅ Good - Defer heavy components @defer (on viewport) { <app-complex-chart /> } @placeholder { <div class="chart-skeleton"></div> }
// ✅ Good - Defer analytics @defer (on idle) { <app-analytics-tracker /> }
- Use @empty for Better UX
// ✅ Good - Handle empty state @for (item of items(); track item.id) { <item-card [item]="item" /> } @empty { <empty-state message="No items found" /> }
- Type Narrowing with @if
// ✅ Good - Type narrowing @if (user(); as currentUser) { <!-- currentUser is guaranteed non-null here --> <div>{{ currentUser.email }}</div> }
🔧 Advanced Patterns
Nested Control Flow
@Component({
template: @if (data(); as currentData) { @for (category of currentData.categories; track category.id) { <div class="category"> <h3>{{ category.name }}</h3> @for (item of category.items; track item.id) { <div class="item">{{ item.title }}</div> } @empty { <p>No items in this category</p> } </div> } } @else { <app-loading /> }
})
Conditional Deferred Loading
@Component({
template: @if (shouldLoadHeavyComponent()) { @defer (on viewport) { <app-heavy-component [config]="config()" /> } @loading { <skeleton-loader /> } }
})
🐛 Troubleshooting
Issue Solution
Syntax error with @ blocks Ensure Angular 20+ and update compiler
@for without track error Always add track expression to @for
@defer not lazy loading Check bundle config and verify component is in separate chunk
Type errors with @if Use as alias for type narrowing
@empty not showing Ensure collection signal returns empty array, not undefined
📖 References
-
Angular Control Flow Guide
-
Angular Defer Guide
-
Migration Guide
📂 Recommended Placement
Project-level skill:
/.github/skills/angular-20-control-flow/SKILL.md
Copilot will load this when working with Angular 20 control flow syntax.