All articles

Angular Directives and Control Flow

@if, @for and @switch, the attribute directives, @defer, and writing your own directive on Angular 22.

0 · log in to like, save & follow Share on LinkedIn Share on X

Components own a patch of screen. Directives attach behaviour to elements that already exist. Between the two sits Angular's template control flow — @if, @for, @switch — which handles the "show this, repeat that" work that structural directives used to do. This article maps the whole landscape on Angular 22, with a working demo including a custom hover-highlight directive, all verified with ng build in the companion repository.

Angular Directives and Control Flow

Control flow: what replaced *ngIf and *ngFor

Every real list has three states — loading, populated, and empty — and built-in blocks express all three without a single import:

Control flow renders the states of one list

<ul>
  @for (p of visibleProducts(); track p.sku) {
    <li>{{ p.name }} — {{ p.stock }} left</li>
  } @empty {
    <li>No products match.</li>
  }
</ul>

@switch (status()) {
  @case ('loading') { <p>Loading…</p> }
  @case ('ready')   { <p>{{ visibleProducts().length }} products shown</p> }
  @default          { <p>Something went wrong.</p> }
}

Four reasons the new syntax won:

  • No CommonModule import. The blocks are part of the template language, not a directive you have to remember to import. A missing import used to produce an element that silently rendered nothing.
  • Sharper type narrowing. Inside @if (user()), the compiler knows the value isn't null. The old *ngIf="user as u" idiom now reads @if (user(); as u).
  • An @empty block, which *ngFor never had — no more *ngIf="items.length === 0" sitting alongside the loop.
  • Mandatory track. This is the quiet performance fix. Without a track expression, replacing an array re-creates every DOM node in the list; with track p.sku, Angular moves and updates only what actually changed. *ngFor made tracking optional, and a decade of applications paid for it in dropped frames.

Migration is mechanical — ng generate @angular/core:control-flow converts an existing codebase for you.

A note on track: use a stable identity, not the loop index. track $index re-uses DOM nodes by position, which is exactly wrong when items are inserted at the top of a list — component state ends up attached to the wrong row. Reach for $index only when the items genuinely have no identity of their own.

Attribute directives: styling and forms

Attribute directives modify the element they sit on:

<li [ngClass]="{ 'sold-out': p.stock === 0 }"
    [ngStyle]="{ fontWeight: p.stock === 0 ? '400' : '600' }">
  • ngClass toggles classes from an object or array. For a single class, plain [class.sold-out]="p.stock === 0" is lighter and needs no import at all.
  • ngStyle sets inline styles from an object; same story — one style reads better as [style.fontWeight]="...".
  • ngModel (from FormsModule) two-way binds a form control:
<label><input type="checkbox" [(ngModel)]="showSoldOut" /> show sold-out</label>

The banana-in-a-box [(ngModel)] is sugar for a property binding plus an event binding. It's fine for a filter checkbox; reactive forms take over once validation gets real.

Writing a custom directive

The demo's appHighlight colours any element on hover:

import { Directive, ElementRef, HostListener, inject, input } from '@angular/core';

@Directive({ selector: '[appHighlight]' })
export class HighlightDirective {
  private readonly el = inject(ElementRef<HTMLElement>);
  readonly appHighlight = input<string>('#fff3bf');   // configurable colour

  @HostListener('mouseenter') onEnter() {
    this.el.nativeElement.style.backgroundColor = this.appHighlight();
  }

  @HostListener('mouseleave') onLeave() {
    this.el.nativeElement.style.backgroundColor = '';
  }
}

Import it like a component, apply it like an attribute:

<li appHighlight="#d3f9d8" [ngClass]="{ 'sold-out': p.stock === 0 }">{{ p.name }}</li>

Four pieces make that work:

  1. An attribute selector, [appHighlight], so the directive attaches to any element carrying that attribute.
  2. ElementRef obtained through inject(), giving access to the host element.
  3. @HostListener for host events. There's a host: {} metadata alternative that avoids the decorator, and with typeCheckHostBindings enabled in Angular 22 those host bindings are type-checked too.
  4. An input() named after the selector, which is what lets appHighlight="#d3f9d8" both apply the directive and pass it a value in one attribute.

Directives compose. The same <li> in the demo carries appHighlight, ngClass, and ngStyle, each contributing one behaviour — that's the design intent. Small orthogonal behaviours beat one component that tries to do everything.

Good candidates for custom directives in real applications: autofocus on appear, click-outside detection, permission-based hiding, input masks, tooltip attachment, and lazy-loading images. The rule of thumb is simple — behaviour without markup is a directive; behaviour with markup is a component.

A note on touching the DOM directly

The directive above writes style.backgroundColor straight onto the element, which is fine for a demo and fine in a browser-only app. If your application is server-rendered, prefer the class-based route:

@Directive({
  selector: '[appHighlight]',
  host: {
    '(mouseenter)': 'hovered.set(true)',
    '(mouseleave)': 'hovered.set(false)',
    '[class.is-hovered]': 'hovered()',
  },
})
export class HighlightDirective {
  readonly hovered = signal(false);
}

Toggling a class instead of writing inline styles keeps the styling in CSS where it belongs, and works identically whether the markup was rendered on the server or in the browser.

@defer: the block worth learning next

@defer is control flow for loading, not just rendering. It lazy-loads everything inside it — component code included — and only when a trigger fires:

@defer (on viewport) {
  <app-product-reviews [sku]="product().sku" />
} @placeholder (minimum 500ms) {
  <div class="skeleton">Reviews</div>
} @loading {
  <p>Loading reviews…</p>
} @error {
  <p>Reviews couldn't load.</p>
}

The triggers cover most real cases: on viewport (when it scrolls into view), on interaction, on hover, on idle, and on timer(2s). You can also drive it from an expression with when isLoggedIn().

What makes this different from an @if is the bundle. Everything inside a @defer block is split into its own chunk, so a heavy reviews widget or a charting library never reaches a visitor who doesn't scroll that far. The @placeholder block is what renders in the meantime, and the minimum duration stops it flashing on a fast connection.

The one constraint to know: components used inside @defer must not also be referenced eagerly elsewhere in the same template, or the bundler has no choice but to include them up front.

Structural directives, the custom kind

You can still write custom structural directives — the * kind that add and remove chunks of template — using TemplateRef and ViewContainerRef. A permission gate is the classic example:

<div *appHasRole="'admin'">Danger zone</div>

With @if covering conditionals and @defer covering lazy rendering, reach for one of these only when you genuinely need template-manipulation semantics the blocks can't express. Most cases today are better served by an @if around a computed() — it's less code and the type narrowing is better.

Three mistakes that cost real time

Forgetting to import the directive. Standalone components declare their own dependencies. If ngClass does nothing, the odds are CommonModule (or NgClass itself) isn't in the component's imports array. Unlike a typo in a property name, this fails silently — the attribute is simply treated as unknown. Turning on strictTemplates catches most of these at build time.

Using track $index on a reorderable list. Covered above, but it's worth repeating because the symptom is so confusing: you delete the second row and the third row's input keeps its old value. Angular re-used the DOM node by position, exactly as instructed.

Doing work in a host listener that runs constantly. @HostListener('mousemove') fires hundreds of times a second, and anything expensive inside it — a layout read, a signal write that cascades — will show up as jank. Throttle it, or use a CSS solution if one exists. The same caution applies to scroll.

Picking the right tool

Need Reach for
Conditional or repeated markup @if / @for (with track)
Toggle one class or style [class.x] / [style.y]
Multiple classes or styles from state ngClass / ngStyle
Form field binding [(ngModel)], then reactive forms
Reusable element behaviour a custom attribute directive
Reusable UI with its own markup a component
Deferring work until visible @defer

Where to go next

Directives round out the template side of Angular. From here, nested components covers the parent–child data contract, and lifecycle hooks explains when each phase of a component runs — including why a directive's host element isn't available in the constructor.

The demo component with all of the above — control-flow blocks, the checkbox filter, and the custom highlight directive — runs from the companion repository: npm install && npm start.

Enjoyed this article? Get the best GeeksArray articles in your inbox — once a week, no spam, unsubscribe anytime.

Comments (0)

Log in to join the conversation.

No comments yet — be the first to share your thoughts.