Directives and Pipes
Before you start
You need: templates and binding (Article 02).
Time: about 45 minutes, plus the practice.
Learning objective
Use Angular's control flow and pipes correctly, and know when a custom directive or pipe is the right tool.
Topics
- Built-in control flow:
@if,@for,@switch - Why
trackmatters @defer- Legacy structural directives
- Attribute directives
- Built-in pipes
- Pure versus impure pipes
- Custom pipes and directives
- The async pipe
Control flow
Angular 17 introduced block syntax, which is now the default.
@if (students.length === 0) {
<p class="muted">No students found.</p>
} @else if (isLoading) {
<p>Loading…</p>
} @else {
<app-student-table [students]="students" />
}
@for (student of students; track student.publicId) {
<app-student-card [student]="student" />
} @empty {
<p class="muted">No students match this search.</p>
}
@empty replaces the separate @if check for an empty list — the block is used when the collection has no items.
Loop context variables:
@for (student of students; track student.publicId; let i = $index, isFirst = $first) {
<tr [class.first-row]="isFirst">
<td>{{ i + 1 }}</td>
<td>{{ student.name }}</td>
</tr>
}
| Variable | Value |
|---|---|
$index | Zero-based position |
$first / $last | Boolean |
$even / $odd | Boolean |
$count | Collection length |
@switch (student.status) {
@case ('Active') { <span class="badge success">Active</span> }
@case ('Inactive') { <span class="badge muted">Inactive</span> }
@case ('Graduated') { <span class="badge info">Graduated</span> }
@default { <span class="badge">Unknown</span> }
}
Block syntax is faster than the old directives — Angular compiles it directly rather than instantiating a template per item — and it needs no CommonModule import.
Why track matters
track is required on @for, and it is not a formality.
@for (student of students; track student.publicId) { … }
Angular uses the track value to decide, when the array changes, which DOM elements to keep and which to rebuild. With a stable identity it moves the existing elements; without one it destroys and recreates all of them.
<!-- Wrong: index changes when the list is sorted or filtered -->
@for (student of students; track $index) { … }
Two consequences, both visible:
- Lost state. Text a user typed into a row's input, an expanded row, a focused field — all destroyed on the next re-render.
- Wasted work. Rebuilding 200 rows when one changed.
Use $index only for a list of primitives with no identity and no per-item state.
@defer
Lazy-loads a block, with its own loading and error states.
@defer (on viewport) {
<app-exam-results-chart [studentId]="student.publicId" />
} @placeholder (minimum 200ms) {
<div class="skeleton" aria-hidden="true"></div>
} @loading (after 100ms; minimum 300ms) {
<p role="status">Loading results…</p>
} @error {
<p class="error" role="alert">Could not load results.</p>
}
| Trigger | Loads when |
|---|---|
on idle | The browser is idle (default) |
on viewport | The placeholder scrolls into view |
on interaction | The user clicks or types |
on hover | The pointer enters |
on timer(5s) | After a delay |
when condition | An expression becomes true |
The component's code is split into a separate chunk and downloaded only when triggered. For a heavy chart below the fold, that is a real reduction in initial bundle size.
minimum on @placeholder and @loading prevents a flicker when the content loads almost instantly.
Legacy structural directives
Every existing project uses these, and they still work.
<p *ngIf="students.length === 0">No students found.</p>
<ng-container *ngIf="student as s; else loading">
<h2>{{ s.name }}</h2>
</ng-container>
<ng-template #loading><p>Loading…</p></ng-template>
<tr *ngFor="let student of students; trackBy: trackByPublicId; let i = index">
<td>{{ i + 1 }}</td>
<td>{{ student.name }}</td>
</tr>
<div [ngSwitch]="student.status">
<span *ngSwitchCase="'Active'">Active</span>
<span *ngSwitchDefault>Unknown</span>
</div>
trackByPublicId(index: number, student: Student): string {
return student.publicId;
}
The * is shorthand for wrapping the element in an <ng-template>. Two structural directives cannot sit on one element — *ngIf and *ngFor together is a compile error. Nest them, or use <ng-container>:
<ng-container *ngIf="canView">
<tr *ngFor="let student of students; trackBy: trackByPublicId">…</tr>
</ng-container>
<ng-container> groups without rendering an element — essential inside a <table>, where a wrapper <div> would be invalid HTML.
Do not convert working *ngIf code as an incidental change. Angular ships a migration schematic when you are ready:
ng generate @angular/core:control-flow
Attribute directives
These change an element rather than adding or removing it.
<div [ngClass]="{ active: isActive, error: hasError }">…</div>
<div [ngClass]="statusClass">…</div>
<div [ngStyle]="{ 'font-weight': isBold ? '600' : '400' }">…</div>
[class.x] and [style.x] are simpler for a single value; ngClass and ngStyle suit a computed set.
Prefer classes to inline styles — an inline style beats every stylesheet rule and cannot be themed.
Built-in pipes
A pipe transforms a value for display.
{{ student.name | uppercase }}
{{ student.name | lowercase }}
{{ student.name | titlecase }}
{{ exam.examDate | date:'dd MMM yyyy' }}
{{ payment.paidOn | date:'dd/MM/yyyy HH:mm' }}
{{ payment.paidOn | date:'medium' }}
{{ feeAccount.totalFees | number:'1.2-2' }}
{{ feeAccount.totalFees | currency:'INR':'symbol':'1.2-2' }}
{{ result.percentage | percent:'1.1-1' }}
{{ student.address | slice:0:50 }}
{{ student | json }}
Chained, left to right:
{{ student.name | slice:0:20 | uppercase }}
| json is a debugging tool — it dumps the object into the page. Useful while developing, and it must never ship: it exposes every field, including ones the screen does not show.
Locale
// app.config.ts
import { registerLocaleData } from '@angular/common';
import localeEnIn from '@angular/common/locales/en-IN';
import { LOCALE_ID } from '@angular/core';
registerLocaleData(localeEnIn);
export const appConfig: ApplicationConfig = {
providers: [
{ provide: LOCALE_ID, useValue: 'en-IN' }
]
};
Without this, | currency:'INR' renders ₹50,000.00 with US grouping rather than the Indian lakh/crore convention, and dates format as M/d/yy.
{{ totalFees | currency:'INR' }} with the locale registered gives ₹50,000.00 grouped correctly.
Pure versus impure pipes
@Pipe({ name: 'classSection', standalone: true, pure: true })
A pure pipe (the default) re-runs only when its input reference changes. An impure pipe re-runs on every change-detection cycle.
// Impure — runs constantly
@Pipe({ name: 'filterStudents', standalone: true, pure: false })
export class FilterStudentsPipe implements PipeTransform {
transform(students: Student[], term: string): Student[] {
return students.filter(s => s.name.includes(term));
}
}
Do not write filtering or sorting pipes. Angular deliberately ships no filter or orderBy pipe, for two reasons: an impure pipe runs on every cycle, and a pure one silently fails to update when the array is mutated in place.
Filter in the component instead:
filtered = computed(() =>
this.students().filter(s =>
s.name.toLowerCase().includes(this.searchTerm().toLowerCase())));
computed recalculates only when a dependency changes — correct and fast.
The async pipe
students$: Observable<Student[]> = this.studentService.getStudents();
@if (students$ | async; as students) {
@for (student of students; track student.publicId) {
<app-student-card [student]="student" />
}
} @else {
<p>Loading…</p>
}
async subscribes, renders each emitted value, and unsubscribes when the component is destroyed.
That last part is why it matters: a manual subscribe() without a matching unsubscribe() is a memory leak, and | async removes the possibility. Observables are covered in article 8.
Subscribing twice is the trap:
<!-- Two subscriptions — two HTTP requests -->
<p>{{ (students$ | async)?.length }} students</p>
@for (student of students$ | async; track student.publicId) { … }
| async; as students subscribes once and reuses the result.
Custom pipes
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({ name: 'classSection', standalone: true })
export class ClassSectionPipe implements PipeTransform {
transform(student: Student | null | undefined): string {
if (!student) {
return '';
}
return `${student.className} - ${student.section}`;
}
}
@Component({
imports: [ClassSectionPipe],
// ...
})
<td>{{ student | classSection }}</td>
@Pipe({ name: 'marksDisplay', standalone: true })
export class MarksDisplayPipe implements PipeTransform {
transform(marks: number | null, isAbsent: boolean): string {
if (isAbsent) {
return 'Absent';
}
if (marks === null) {
return '—';
}
return marks.toFixed(1);
}
}
<td>{{ result.marksObtained | marksDisplay:result.isAbsent }}</td>
The absent check comes first, so an absent student with null marks never renders as a score. Extra arguments follow the value, colon-separated.
Handle null and undefined in every pipe. Data arrives asynchronously, so a pipe runs against null before the response lands.
Custom directives
import { Directive, ElementRef, Input, OnInit, inject } from '@angular/core';
@Directive({
selector: '[appAutoFocus]',
standalone: true
})
export class AutoFocusDirective implements OnInit {
private readonly element = inject(ElementRef<HTMLElement>);
@Input() appAutoFocus = true;
ngOnInit(): void {
if (this.appAutoFocus) {
this.element.nativeElement.focus();
}
}
}
<input appAutoFocus>
<input [appAutoFocus]="isEditing">
A structural directive, using TemplateRef and ViewContainerRef:
@Directive({ selector: '[appIfRole]', standalone: true })
export class IfRoleDirective {
private readonly templateRef = inject(TemplateRef<unknown>);
private readonly viewContainer = inject(ViewContainerRef);
private readonly auth = inject(AuthService);
@Input() set appIfRole(role: string) {
this.viewContainer.clear();
if (this.auth.hasRole(role)) {
this.viewContainer.createEmbeddedView(this.templateRef);
}
}
}
<button *appIfRole="'Admin'" (click)="deleteStudent()">Delete</button>
Hiding a button is not authorisation. Anyone can call the API directly, and the token's claims are readable by the client. This improves the interface; the server must still enforce the rule.
Prefix custom directive selectors — appAutoFocus, not autoFocus — so they never collide with a real HTML attribute or a library's.
Errors you will hit
| What you see | Cause | Fix |
|---|---|---|
| List re-renders entirely on every change | No track in @for | Add track item.publicId |
NG0955: track expression resulted in duplicated keys | Track key is not unique | Use a genuinely unique field |
| A pipe runs on every change detection cycle | Pipe marked impure | Keep pipes pure |
date pipe shows the wrong day | Timezone conversion | Pass the timezone explicitly |
| Structural directive does nothing | Missing the *, or the module is not imported | Check both |
track is not an optimisation detail. Without it Angular destroys and rebuilds every row, losing focus and scroll position on each update.
Common mistakes
track $indexon a list with per-item state- Omitting
trackentirely - Writing a filter or sort pipe
- An impure pipe on a hot path
| jsonshipped to production- Two
*directives on one element - A
<div>wrapper inside a<table>instead of<ng-container> - Subscribing twice with
| async - A pipe that does not handle
null - No locale registered, so currency and dates format wrongly
- A custom pipe or directive without a prefix
- Treating a hidden button as an access control
- Converting
*ngIfto@ifas an incidental change
Practice
- Render a student list with
@for,trackonpublicId, and an@emptyblock. - Change
trackto$index. Add a text input to each row, type into one, then sort the list. Confirm the values move to the wrong rows. - Restore
track student.publicIdand confirm the values follow their rows. - Add
@if/@else if/@elsefor loading, empty and loaded states. - Use
@switchfor the status badge. - Add
@defer (on viewport)around a heavy component with placeholder, loading and error blocks. Confirm the separate chunk in the Network tab. - Register the
en-INlocale and format a fee amount with| currency:'INR'. Compare before and after. - Write a
filterStudentsimpure pipe and log insidetransform. Count the calls while typing elsewhere. - Replace it with a
computedsignal and compare the call count. - Write
ClassSectionPipeandMarksDisplayPipe. Confirm an absent result renders "Absent", not0. - Pass
nullto your pipe and confirm it does not throw. - Write
AutoFocusDirectiveand apply it to a search box. - Write
*appIfRoleand hide a delete button. Then call the delete API directly with the same token and confirm the button was not a control. - Use
| async; as studentsand confirm one HTTP request. Subscribe twice and confirm two.
Exercises 2 and 14 correspond to two bugs that reach production regularly.
You can now
- Use
@if,@forand@switchcorrectly - Always supply a unique
trackexpression - Transform values with built-in pipes
- Write a pure custom pipe
- Say why an impure pipe is a performance risk
Review questions
- Why does
track $indexlose row state? - Why does Angular ship no filter or sort pipe?
- What does the
asyncpipe do that a manual subscription does not? - Why is hiding a button with a directive not authorisation?