Module Overview
Estimated Time: 3-4 hours | Difficulty: Intermediate | Prerequisites: Module 2
- 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.*ngIf (Legacy) vs @if (Modern)
Copy
@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)
Copy
@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
Copy
@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
Copy
@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
Copy
@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
Copy
// 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;
}
}
Copy
<!-- 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
Copy
// 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;
}
}
}
Copy
<!-- Usage: opposite of *ngIf -->
<p *appUnless="isLoggedIn">Please log in to continue.</p>
Using HostBinding and HostListener
Copy
// 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
Copy
@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
Copy
<!-- Pipes can be chained -->
<p>{{ today | date:'fullDate' | uppercase }}</p>
<p>{{ name | slice:0:10 | uppercase }}</p>
Custom Pipe
Copy
// 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';
}
}
Copy
<!-- Usage -->
<p>Posted: {{ post.createdAt | timeAgo }}</p>
Filter Pipe
Copy
// 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);
}
}
Copy
<!-- Usage -->
@for (user of users | filter:'role':'admin'; track user.id) {
<p>{{ user.name }} - Admin</p>
}
Pure vs Impure Pipes
Copy
// 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! They run on every change detection cycle, which can cause performance issues. Instead, use pure pipes and ensure you create new array/object references when data changes.
Deferrable Views (@defer)
Angular 17+ introduces deferrable views for lazy loading content:Copy
@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
- Create a
DebounceClickDirectivethat prevents rapid clicks - Create a
TruncatePipethat shortens text with ellipsis
- Directive should have configurable delay (default 300ms)
- Pipe should accept max length parameter
Solution
Solution
Copy
// 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
Next Steps
Next: Services & Dependency Injection
Learn about injectable services and Angular’s powerful DI system