Skip to main content
Published / updated

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 track matters
  • @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>
}
VariableValue
$indexZero-based position
$first / $lastBoolean
$even / $oddBoolean
$countCollection 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>
}
TriggerLoads when
on idleThe browser is idle (default)
on viewportThe placeholder scrolls into view
on interactionThe user clicks or types
on hoverThe pointer enters
on timer(5s)After a delay
when conditionAn 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 seeCauseFix
List re-renders entirely on every changeNo track in @forAdd track item.publicId
NG0955: track expression resulted in duplicated keysTrack key is not uniqueUse a genuinely unique field
A pipe runs on every change detection cyclePipe marked impureKeep pipes pure
date pipe shows the wrong dayTimezone conversionPass the timezone explicitly
Structural directive does nothingMissing the *, or the module is not importedCheck 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 $index on a list with per-item state
  • Omitting track entirely
  • Writing a filter or sort pipe
  • An impure pipe on a hot path
  • | json shipped 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 *ngIf to @if as an incidental change

Practice

  1. Render a student list with @for, track on publicId, and an @empty block.
  2. Change track to $index. Add a text input to each row, type into one, then sort the list. Confirm the values move to the wrong rows.
  3. Restore track student.publicId and confirm the values follow their rows.
  4. Add @if / @else if / @else for loading, empty and loaded states.
  5. Use @switch for the status badge.
  6. Add @defer (on viewport) around a heavy component with placeholder, loading and error blocks. Confirm the separate chunk in the Network tab.
  7. Register the en-IN locale and format a fee amount with | currency:'INR'. Compare before and after.
  8. Write a filterStudents impure pipe and log inside transform. Count the calls while typing elsewhere.
  9. Replace it with a computed signal and compare the call count.
  10. Write ClassSectionPipe and MarksDisplayPipe. Confirm an absent result renders "Absent", not 0.
  11. Pass null to your pipe and confirm it does not throw.
  12. Write AutoFocusDirective and apply it to a search box.
  13. Write *appIfRole and hide a delete button. Then call the delete API directly with the same token and confirm the button was not a control.
  14. Use | async; as students and 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, @for and @switch correctly
  • Always supply a unique track expression
  • Transform values with built-in pipes
  • Write a pure custom pipe
  • Say why an impure pipe is a performance risk

Review questions

  1. Why does track $index lose row state?
  2. Why does Angular ship no filter or sort pipe?
  3. What does the async pipe do that a manual subscription does not?
  4. Why is hiding a button with a directive not authorisation?

Next: Services and dependency injection