New Built-In Control Flow — @if, @for, @switch in Angular 17+

Angular 17 introduced built-in control flow syntax (@if, @for, @switch) as the modern replacement for structural directives (*ngIf, *ngFor, *ngSwitch). The new syntax is part of the template compiler itself — not a library directive — making it faster, better at type narrowing, and simpler to use. The track expression in @for is mandatory (not optional like trackBy was), which enforces a performance best practice that was previously easy to skip. All new Angular 18 code should use the new syntax.

New Built-In Control Flow

@Component({
  selector:   'app-post-list',
  standalone:  true,
  template: `
    <!-- ── @if / @else if / @else ─────────────────────────────────────── -->
    @if (isLoading()) {
      <app-loading-spinner />
    } @else if (error()) {
      <p class="error">{{ error() }}</p>
    } @else if (posts().length === 0) {
      <p class="empty">No posts found.</p>
    } @else {
      <div class="post-grid">
        <!-- ── @for with mandatory track ──────────────────────────────── -->
        @for (post of posts(); track post.id) {
          <app-post-card [post]="post" />
        } @empty {
          <p>The list is empty.</p>
        }
      </div>
      <!-- ── @for with index ────────────────────────────────────────────── -->
      @for (tag of tags(); track tag; let i = $index, last = $last) {
        <span class="tag">#{{ i + 1 }}. {{ tag }}{{ last ? '' : ', ' }}</span>
      }
    }

    <!-- ── @switch / @case / @default ─────────────────────────────────── -->
    @switch (post().status) {
      @case ('draft')     { <span class="badge draft">Draft</span>     }
      @case ('review')    { <span class="badge review">In Review</span> }
      @case ('published') { <span class="badge pub">Published</span>   }
      @default            { <span class="badge">Unknown</span>          }
    }
  `,
})
export class PostListComponent {
  posts     = signal<PostSummaryDto[]>([]);
  tags      = signal<string[]>([]);
  isLoading = signal(true);
  error     = signal<string | null>(null);
  post      = signal<PostDto | null>(null);
}

// ── @for implicit variables ────────────────────────────────────────────────
// $index   — 0-based position in the collection
// $first   — true for the first item
// $last    — true for the last item
// $even    — true for even-indexed items (0, 2, 4...)
// $odd     — true for odd-indexed items (1, 3, 5...)
// $count   — total number of items in the collection

// ── track expression — required, replaces trackBy ─────────────────────────
// track post.id          — by primary key (recommended for API data)
// track post.slug        — by unique slug
// track $index           — by array position (only when items have no stable ID)
// track item             — by object identity (risky — same object, different pos)
Note: The track expression in @for tells Angular which value uniquely identifies each item. When the list changes, Angular uses track to determine which DOM nodes to reuse, move, or destroy rather than recreating everything from scratch. track post.id means “if a post with this ID already exists in the DOM, reuse that node.” Without correct tracking, Angular destroys and recreates all DOM nodes on every list update — losing focus state, triggering CSS transition re-runs, and causing poor performance with large lists.
Tip: The @empty block inside @for renders when the collection has zero items — it is the idiomatic replacement for the *ngIf="items.length === 0" pattern outside the loop. Use @empty rather than a separate @if for the empty state: it is co-located with the loop it relates to, making the template logic easier to follow. The @empty block renders before the loop’s items are checked, not as an alternative to @if on the container.
Warning: Avoid using track $index when the list order can change or items can be inserted/deleted in the middle. With track $index, when an item is deleted from position 2, Angular thinks every item from position 2 onward has changed (because their indexes changed), and recreates all their DOM nodes. With track post.id, Angular correctly identifies which specific post was deleted and removes only that DOM node, leaving all others untouched.

Type Narrowing with @if

// ── @if improves TypeScript type narrowing ────────────────────────────────
post = signal<PostDto | null>(null);

// Template:
// @if (post()) {
//   {{ post().title }}   <!-- TypeScript knows post() is PostDto here, not null ── -->
// }

// With *ngIf — type narrowing is less reliable in older Angular:
// <div *ngIf="post">{{ post.title }}</div>   <!-- may still warn about null ─── -->

Common Mistakes

Mistake 1 — Forgetting track in @for (compile error in Angular 17+)

❌ Wrong — @for (post of posts()); compiler error: “Missing ‘track’ in @for block”.

✅ Correct — always specify track post.id or another stable unique identifier.

Mistake 2 — Using @switch without @default (unhandled cases render nothing)

❌ Wrong — no @default block; unknown status values render nothing with no indication.

✅ Correct — always include a @default block as a fallback for unexpected values.

🧠 Test Yourself

A post list uses @for (post of posts(); track post.id). A new post is added to the beginning of the array. What does Angular do to the existing DOM nodes?