Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

Module Overview

Estimated Time: 3-4 hours | Difficulty: Intermediate | Prerequisites: Module 2
Directives add behavior to DOM elements, and pipes transform data for display. Together, they make your templates powerful and expressive. Think of directives as instructions you attach to HTML elements — “show this element only if the user is logged in” or “highlight this text when the mouse hovers over it.” Think of pipes as data formatters — they take a raw value (like a timestamp or a number) and transform it into something human-friendly (like “3 hours ago” or “$1,299.99”) without changing the underlying data. What You’ll Learn:
  • Structural directives (*ngIf, *ngFor, *ngSwitch)
  • Modern control flow syntax (@if, @for, @switch)
  • Attribute directives (ngClass, ngStyle)
  • Creating custom directives
  • Built-in pipes and custom pipe creation
  • Pure vs impure pipes

Structural Directives

Structural directives change the DOM layout by adding, removing, or manipulating elements. They are called “structural” because they alter the structure of the DOM tree itself — not just styling or attributes, but whether elements exist at all.
Legacy vs Modern syntax: Angular 17 introduced the @if, @for, and @switch control flow syntax as a replacement for *ngIf, *ngFor, and *ngSwitch. The new syntax is recommended for all new projects — it is more readable, performs better (the @for block is up to 90% faster than *ngFor in benchmarks), and does not require importing directives. This module shows both so you can work with existing codebases.

*ngIf (Legacy) vs @if (Modern)

@Component({
  selector: 'app-user-status',
  standalone: true,
  imports: [NgIf],  // Only needed for *ngIf
  template: `
    <!-- Legacy *ngIf syntax -->
    <div *ngIf="user">
      <p>Welcome, {{ user.name }}!</p>
    </div>
    <div *ngIf="!user">
      <p>Please log in.</p>
    </div>
    
    <!-- With else -->
    <div *ngIf="user; else loginPrompt">
      Welcome, {{ user.name }}!
    </div>
    <ng-template #loginPrompt>
      <p>Please log in.</p>
    </ng-template>
    
    <!-- Modern @if syntax (Angular 17+) - Recommended! -->
    @if (user) {
      <p>Welcome, {{ user.name }}!</p>
    } @else {
      <p>Please log in.</p>
    }
    
    <!-- @if with else-if -->
    @if (user.role === 'admin') {
      <p>Admin Dashboard</p>
    } @else if (user.role === 'user') {
      <p>User Dashboard</p>
    } @else {
      <p>Guest View</p>
    }
  `
})
export class UserStatusComponent {
  user?: { name: string; role: string };
}

*ngFor (Legacy) vs @for (Modern)

@Component({
  selector: 'app-user-list',
  standalone: true,
  imports: [NgFor],  // Only needed for *ngFor
  template: `
    <!-- Legacy *ngFor syntax -->
    <ul>
      <li *ngFor="let user of users; let i = index; let first = first; 
                  let last = last; let even = even; let odd = odd;
                  trackBy: trackByUserId">
        {{ i + 1 }}. {{ user.name }}
        <span *ngIf="first">(First)</span>
        <span *ngIf="last">(Last)</span>
      </li>
    </ul>
    
    <!-- Modern @for syntax (Angular 17+) - Recommended! -->
    <ul>
      @for (user of users; track user.id; let i = $index, 
            first = $first, last = $last) {
        <li>
          {{ i + 1 }}. {{ user.name }}
          @if (first) { <span>(First)</span> }
          @if (last) { <span>(Last)</span> }
        </li>
      } @empty {
        <li>No users found.</li>
      }
    </ul>
  `
})
export class UserListComponent {
  users = [
    { id: 1, name: 'Alice' },
    { id: 2, name: 'Bob' },
    { id: 3, name: 'Charlie' }
  ];
  
  // Legacy trackBy function
  trackByUserId(index: number, user: { id: number }) {
    return user.id;
  }
}
Always use track! Without tracking, Angular rebuilds the entire list on changes. With track user.id, it only updates changed items.

*ngSwitch vs @switch

@Component({
  selector: 'app-status-badge',
  standalone: true,
  imports: [NgSwitch, NgSwitchCase, NgSwitchDefault],
  template: `
    <!-- Legacy ngSwitch -->
    <div [ngSwitch]="status">
      <span *ngSwitchCase="'active'" class="badge green">Active</span>
      <span *ngSwitchCase="'pending'" class="badge yellow">Pending</span>
      <span *ngSwitchCase="'inactive'" class="badge red">Inactive</span>
      <span *ngSwitchDefault class="badge gray">Unknown</span>
    </div>
    
    <!-- Modern @switch (Angular 17+) -->
    @switch (status) {
      @case ('active') {
        <span class="badge green">Active</span>
      }
      @case ('pending') {
        <span class="badge yellow">Pending</span>
      }
      @case ('inactive') {
        <span class="badge red">Inactive</span>
      }
      @default {
        <span class="badge gray">Unknown</span>
      }
    }
  `
})
export class StatusBadgeComponent {
  status: 'active' | 'pending' | 'inactive' | 'unknown' = 'active';
}

Attribute Directives

Attribute directives change the appearance or behavior of an element.

ngClass

@Component({
  selector: 'app-button',
  standalone: true,
  imports: [NgClass],
  template: `
    <!-- String syntax -->
    <button [ngClass]="'btn btn-primary'">Button</button>
    
    <!-- Object syntax -->
    <button [ngClass]="{
      'btn': true,
      'btn-primary': isPrimary,
      'btn-large': size === 'large',
      'disabled': isDisabled
    }">
      Button
    </button>
    
    <!-- Array syntax -->
    <button [ngClass]="['btn', buttonClass]">Button</button>
    
    <!-- Method returning class object -->
    <button [ngClass]="getButtonClasses()">Button</button>
  `
})
export class ButtonComponent {
  isPrimary = true;
  size = 'large';
  isDisabled = false;
  buttonClass = 'btn-primary';
  
  getButtonClasses() {
    return {
      'btn': true,
      'btn-primary': this.isPrimary,
      'btn-disabled': this.isDisabled
    };
  }
}

ngStyle

@Component({
  selector: 'app-styled-box',
  standalone: true,
  imports: [NgStyle],
  template: `
    <!-- Object syntax -->
    <div [ngStyle]="{
      'background-color': bgColor,
      'width.px': width,
      'height.px': height,
      'font-size.rem': 1.5
    }">
      Styled Box
    </div>
    
    <!-- Method returning style object -->
    <div [ngStyle]="getStyles()">Dynamic Styles</div>
  `
})
export class StyledBoxComponent {
  bgColor = '#DD0031';
  width = 200;
  height = 100;
  
  getStyles() {
    return {
      backgroundColor: this.bgColor,
      width: `${this.width}px`,
      height: `${this.height}px`
    };
  }
}

Custom Directives

Attribute Directive

// highlight.directive.ts
import { Directive, ElementRef, HostListener, Input } from '@angular/core';

@Directive({
  selector: '[appHighlight]',
  standalone: true
})
export class HighlightDirective {
  @Input() appHighlight = 'yellow';  // Highlight color
  @Input() defaultColor = 'transparent';
  
  constructor(private el: ElementRef<HTMLElement>) {}
  
  @HostListener('mouseenter')
  onMouseEnter() {
    this.highlight(this.appHighlight || 'yellow');
  }
  
  @HostListener('mouseleave')
  onMouseLeave() {
    this.highlight(this.defaultColor);
  }
  
  private highlight(color: string) {
    this.el.nativeElement.style.backgroundColor = color;
  }
}
<!-- Usage -->
<p appHighlight>Highlight me on hover (yellow)</p>
<p [appHighlight]="'lightblue'">Highlight me (light blue)</p>
<p [appHighlight]="'pink'" [defaultColor]="'lightgray'">
  Pink highlight, gray default
</p>

Structural Directive

// unless.directive.ts
import { 
  Directive, 
  Input, 
  TemplateRef, 
  ViewContainerRef 
} from '@angular/core';

@Directive({
  selector: '[appUnless]',
  standalone: true
})
export class UnlessDirective {
  private hasView = false;
  
  constructor(
    private templateRef: TemplateRef<any>,
    private viewContainer: ViewContainerRef
  ) {}
  
  @Input() set appUnless(condition: boolean) {
    if (!condition && !this.hasView) {
      // Show the template
      this.viewContainer.createEmbeddedView(this.templateRef);
      this.hasView = true;
    } else if (condition && this.hasView) {
      // Hide the template
      this.viewContainer.clear();
      this.hasView = false;
    }
  }
}
<!-- Usage: opposite of *ngIf -->
<p *appUnless="isLoggedIn">Please log in to continue.</p>

Using HostBinding and HostListener

// tooltip.directive.ts
import { 
  Directive, 
  Input, 
  HostBinding, 
  HostListener 
} from '@angular/core';

@Directive({
  selector: '[appTooltip]',
  standalone: true
})
export class TooltipDirective {
  @Input() appTooltip = '';
  
  @HostBinding('class.has-tooltip') hasTooltip = true;
  @HostBinding('attr.data-tooltip') 
  get tooltipText() { return this.appTooltip; }
  
  @HostListener('mouseenter')
  onMouseEnter() {
    // Show tooltip logic
  }
  
  @HostListener('mouseleave')
  onMouseLeave() {
    // Hide tooltip logic
  }
}

Pipes

Pipes transform data for display in templates.

Built-in Pipes

@Component({
  selector: 'app-pipes-demo',
  standalone: true,
  imports: [
    DatePipe, 
    CurrencyPipe, 
    DecimalPipe, 
    PercentPipe,
    UpperCasePipe, 
    LowerCasePipe, 
    TitleCasePipe,
    SlicePipe, 
    JsonPipe, 
    AsyncPipe,
    KeyValuePipe
  ],
  template: `
    <!-- Date Pipe -->
    <p>Today: {{ today | date }}</p>
    <p>Full: {{ today | date:'fullDate' }}</p>
    <p>Custom: {{ today | date:'yyyy-MM-dd HH:mm' }}</p>
    <p>Relative: {{ today | date:'short' }}</p>
    
    <!-- Currency Pipe -->
    <p>Price: {{ price | currency }}</p>
    <p>EUR: {{ price | currency:'EUR' }}</p>
    <p>Custom: {{ price | currency:'USD':'symbol':'1.0-0' }}</p>
    
    <!-- Number Pipes -->
    <p>Number: {{ pi | number:'1.2-4' }}</p>
    <p>Percent: {{ ratio | percent:'1.1-1' }}</p>
    
    <!-- String Pipes -->
    <p>Upper: {{ name | uppercase }}</p>
    <p>Lower: {{ name | lowercase }}</p>
    <p>Title: {{ 'hello world' | titlecase }}</p>
    
    <!-- Slice Pipe -->
    <p>Slice: {{ name | slice:0:5 }}</p>
    <p>Array: {{ items | slice:0:3 }}</p>
    
    <!-- JSON Pipe (debugging) -->
    <pre>{{ user | json }}</pre>
    
    <!-- Async Pipe (auto subscribes!) -->
    @if (users$ | async; as users) {
      <ul>
        @for (user of users; track user.id) {
          <li>{{ user.name }}</li>
        }
      </ul>
    }
    
    <!-- KeyValue Pipe -->
    @for (item of object | keyvalue; track item.key) {
      <p>{{ item.key }}: {{ item.value }}</p>
    }
  `
})
export class PipesDemoComponent {
  today = new Date();
  price = 42.5;
  pi = 3.14159265;
  ratio = 0.856;
  name = 'Angular Developer';
  items = [1, 2, 3, 4, 5];
  user = { name: 'John', age: 30 };
  users$ = of([{ id: 1, name: 'Alice' }]);
  object = { a: 1, b: 2, c: 3 };
}

Chaining Pipes

<!-- Pipes can be chained -->
<p>{{ today | date:'fullDate' | uppercase }}</p>
<p>{{ name | slice:0:10 | uppercase }}</p>

Custom Pipe

// time-ago.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
  name: 'timeAgo',
  standalone: true
})
export class TimeAgoPipe implements PipeTransform {
  transform(value: Date | string | number): string {
    const date = new Date(value);
    const now = new Date();
    const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
    
    const intervals = [
      { label: 'year', seconds: 31536000 },
      { label: 'month', seconds: 2592000 },
      { label: 'week', seconds: 604800 },
      { label: 'day', seconds: 86400 },
      { label: 'hour', seconds: 3600 },
      { label: 'minute', seconds: 60 },
      { label: 'second', seconds: 1 }
    ];
    
    for (const interval of intervals) {
      const count = Math.floor(seconds / interval.seconds);
      if (count >= 1) {
        return `${count} ${interval.label}${count > 1 ? 's' : ''} ago`;
      }
    }
    
    return 'just now';
  }
}
<!-- Usage -->
<p>Posted: {{ post.createdAt | timeAgo }}</p>

Filter Pipe

// filter.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
  name: 'filter',
  standalone: true
})
export class FilterPipe implements PipeTransform {
  transform<T>(items: T[], field: keyof T, value: any): T[] {
    if (!items || !field || value === undefined) {
      return items;
    }
    return items.filter(item => item[field] === value);
  }
}
<!-- Usage -->
@for (user of users | filter:'role':'admin'; track user.id) {
  <p>{{ user.name }} - Admin</p>
}

Pure vs Impure Pipes

// Pure pipe (default) - only runs when input reference changes
@Pipe({
  name: 'purePipe',
  standalone: true,
  pure: true  // default
})
export class PurePipe implements PipeTransform {
  transform(value: any[]): any[] {
    console.log('Pure pipe executed');
    return value.filter(v => v.active);
  }
}

// Impure pipe - runs on every change detection cycle
@Pipe({
  name: 'impurePipe',
  standalone: true,
  pure: false  // impure
})
export class ImpurePipe implements PipeTransform {
  transform(value: any[]): any[] {
    console.log('Impure pipe executed');  // Called frequently!
    return value.filter(v => v.active);
  }
}
Avoid impure pipes in most cases. They run on every change detection cycle, which can mean dozens or hundreds of executions per second during user interaction. To put this in perspective: if you have a list of 100 items and an impure filter pipe, that pipe’s transform method could execute thousands of times per minute. Instead, use pure pipes and ensure you create new array/object references when data changes — this is the immutable data pattern that Angular is optimized for.

Deferrable Views (@defer)

Angular 17+ introduces deferrable views for lazy loading content. This is one of Angular’s most powerful performance features — it lets you tell the framework “do not even download the JavaScript for this component until a specific trigger occurs.” The component code stays out of your initial bundle entirely, which can dramatically reduce your Time to Interactive on first page load. Common real-world uses: heavy charting libraries that only load when scrolled into view, comment sections that load on click, and preview cards that load on hover.
@Component({
  selector: 'app-dashboard',
  standalone: true,
  template: `
    <h1>Dashboard</h1>
    
    <!-- Lazy load heavy component -->
    @defer {
      <app-heavy-chart></app-heavy-chart>
    } @placeholder {
      <p>Chart will load shortly...</p>
    } @loading (minimum 500ms) {
      <app-spinner></app-spinner>
    } @error {
      <p>Failed to load chart</p>
    }
    
    <!-- Defer with trigger -->
    @defer (on viewport) {
      <app-comments></app-comments>
    } @placeholder {
      <div style="height: 200px">Comments will load when scrolled into view</div>
    }
    
    <!-- Defer on interaction -->
    @defer (on interaction) {
      <app-video-player></app-video-player>
    } @placeholder {
      <button>Click to load video player</button>
    }
    
    <!-- Defer on hover -->
    @defer (on hover) {
      <app-preview-card></app-preview-card>
    } @placeholder {
      <p>Hover to load preview</p>
    }
    
    <!-- Defer with timer -->
    @defer (on timer(2s)) {
      <app-delayed-content></app-delayed-content>
    }
    
    <!-- Defer when condition is true -->
    @defer (when isDataLoaded) {
      <app-data-display></app-data-display>
    }
    
    <!-- Prefetch strategy -->
    @defer (on viewport; prefetch on idle) {
      <app-large-component></app-large-component>
    }
  `
})
export class DashboardComponent {
  isDataLoaded = false;
}

Practice Exercise

Exercise: Create a Directive and Pipe

  1. Create a DebounceClickDirective that prevents rapid clicks
  2. Create a TruncatePipe that shortens text with ellipsis
Requirements:
  • Directive should have configurable delay (default 300ms)
  • Pipe should accept max length parameter
// debounce-click.directive.ts
@Directive({
  selector: '[appDebounceClick]',
  standalone: true
})
export class DebounceClickDirective {
  @Input() debounceTime = 300;
  @Output() debounceClick = new EventEmitter();
  
  private clicks$ = new Subject<MouseEvent>();
  
  constructor() {
    this.clicks$.pipe(
      debounceTime(this.debounceTime)
    ).subscribe(e => this.debounceClick.emit(e));
  }
  
  @HostListener('click', ['$event'])
  onClick(event: MouseEvent) {
    event.preventDefault();
    event.stopPropagation();
    this.clicks$.next(event);
  }
}

// truncate.pipe.ts
@Pipe({
  name: 'truncate',
  standalone: true
})
export class TruncatePipe implements PipeTransform {
  transform(value: string, maxLength = 50, suffix = '...'): string {
    if (!value) return '';
    if (value.length <= maxLength) return value;
    return value.substring(0, maxLength).trim() + suffix;
  }
}

// Usage
`<button appDebounceClick (debounceClick)="save()">Save</button>`
`<p>{{ longText | truncate:100:'...' }}</p>`

Summary

1

Structural Directives

Use @if, @for, @switch (modern) or *ngIf, *ngFor, *ngSwitch (legacy) to control DOM structure
2

Attribute Directives

ngClass and ngStyle for dynamic styling, create custom with @Directive
3

Pipes

Transform data for display with built-in or custom pipes
4

@defer

Lazy load content with viewport, interaction, or timer triggers

Interview Deep-Dive

Strong Answer: A pure pipe only re-executes its transform method when the input value reference changes. An impure pipe re-executes on every single change detection cycle, regardless of whether its input changed. This distinction has massive performance implications.With 500 items and an impure filter pipe, the pipe’s transform method runs on every change detection cycle — which could be dozens of times per second during user interaction (typing, scrolling, mouse movement). If each invocation iterates 500 items, you are doing thousands of array iterations per second for no reason. I have seen this cause visible jank in production.A pure pipe with the same 500 items only re-runs when you create a new array reference. So if you use immutable patterns (filter returns new array, spread operator for updates), the pipe runs once per actual data change. The trick is understanding that pushing to an existing array does NOT trigger a pure pipe because the array reference is the same. You must do this.items = […this.items, newItem] instead of this.items.push(newItem).The practical rule: always use pure pipes. If you think you need an impure pipe, you almost certainly need to fix your data flow to use immutable updates instead.Follow-up: How does the new @for block with track affect this equation? Answer: The @for block’s track expression tells Angular which items changed so it can update only those DOM elements. Combined with a pure pipe that returns a new array, Angular’s diffing algorithm sees exactly which items were added, removed, or moved. Without track, Angular destroys and recreates the entire list on every change. Track does not affect how often the pipe runs — it affects how efficiently Angular updates the DOM after the pipe produces a new array.
Strong Answer: Structural directives manipulate the DOM by adding or removing elements. Under the hood, Angular transforms the asterisk syntax into an ng-template. So *appIfPermission=“‘admin’” becomes <ng-template [appIfPermission]=“‘admin’”>…content…</ng-template>. The directive receives a TemplateRef (the template to render) and a ViewContainerRef (where to render it) via dependency injection.For the permission directive, I would inject the AuthService to check the user’s permissions, create or clear the embedded view based on the check, and subscribe to permission changes for dynamic updates. The key implementation detail: you need to track whether the view is currently created (a boolean flag) to avoid creating duplicate views or clearing an already-empty container.Here is the mental model: TemplateRef is the blueprint, ViewContainerRef is the construction site. createEmbeddedView builds the blueprint at the site, and clear demolishes whatever is there. You can even create the same template multiple times (which is how *ngFor works internally — one template, multiple embedded views).Follow-up: How would you handle the case where permissions change after the directive initializes — for example, the user’s role is upgraded while they are on the page? Answer: I would use an @Input setter that re-evaluates whenever the permission string changes, combined with a subscription to the AuthService’s user observable. When either the required permission or the user’s actual permissions change, I re-evaluate and either create or clear the view. I would use takeUntilDestroyed to clean up the subscription. This makes the directive fully reactive — it responds to both input changes and runtime permission changes.
Strong Answer: @defer blocks and lazy-loaded routes both defer JavaScript loading, but they operate at different granularities and with different triggers. Lazy-loaded routes defer entire feature modules until the user navigates to that route. @defer blocks defer individual components within a page based on triggers like viewport visibility, user interaction, idle time, or custom conditions.The key difference: lazy routes are navigation-driven, @defer is rendering-driven. A lazy route only loads when the URL changes. A @defer block can load when a component scrolls into view (on viewport), when the user hovers over a placeholder (on hover), or when the browser is idle (on idle). This is much more granular.I use lazy routes for feature-level code splitting — the admin section, the settings page, the checkout flow. I use @defer for component-level optimization within a page — a heavy chart library that only loads when scrolled into view, a comment section that loads on click, or a rich text editor that loads when the user starts interacting with the form. The two are complementary, not competing. A lazy-loaded route page can itself contain @defer blocks for its heavy components.Follow-up: What happens if a @defer block fails to load — say the network drops? Answer: That is what the @error block is for. You can define @defer, @loading, @placeholder, and @error blocks. @placeholder shows before loading starts, @loading shows during the network fetch (with an optional minimum display time to prevent flickering), and @error shows if the chunk fails to load. You can also combine prefetching with trigger — for example, prefetch on idle but trigger on viewport, so the code is likely already cached by the time the user scrolls to it.

Next Steps

Next: Services & Dependency Injection

Learn about injectable services and Angular’s powerful DI system