Angular Basics #4: Directives and Pipes
Last time, we covered data binding and event handling, looking at how a component and its template exchange data. This time, we’ll cover two tools that make templates more expressive — Directives and Pipes. Branching the view based on conditions, looping over an array to render it, transforming a value into a presentable form — these are features used on almost every screen.
What is a Directive #
A Directive is a class that gives extra behavior to the DOM. Instructions like “decide whether to show this element based on a condition,” “repeat over this array and render,” or “apply this style to this element” — those are the kinds of directives expressed inside a template.
In fact, the Component we built in #2 is also a kind of Directive. To be precise, a “directive with a template” is a component. Beyond that, Angular provides two more kinds of directives.
- Structural Directive — directives that change the structure of the DOM itself. They add, remove, or repeat elements.
- Attribute Directive — directives that only change the appearance or behavior of an element. They toggle classes or apply styles.
Let’s start with structural directives. The new control flow introduced in Angular 17 is the key.
The new control flow — @if, @for, @switch #
For a long time, Angular handled branching and looping with structural directives like *ngIf, *ngFor, and *ngSwitch. From Angular 17, a replacement Built-in Control Flow syntax was introduced, and for new projects, this should be the standard. It’s faster, has better type inference, and needs no separate import.
@if — conditional rendering #
To show or hide an element based on a condition, use @if.
@if (user) {
<p>Hello, {{ user.name }}!</p>
} @else {
<p>You need to log in.</p>
}You can build multiple branches with else if too.
@if (status === 'loading') {
<p>Loading...</p>
} @else if (status === 'error') {
<p class="error">An error occurred.</p>
} @else {
<p>Done!</p>
}It’s almost the same as JavaScript’s if/else if/else, so it’s intuitive.
@for — repeat rendering #
To loop over an array and render, use @for.
<ul>
@for (post of posts; track post.id) {
<li>{{ post.title }}</li>
} @empty {
<li>No posts yet.</li>
}
</ul>The most important part of @for is the track expression. When the array changes, Angular uses the value specified in track to identify which items were added, removed, or moved. Usually you pass a unique identifier like post.id. If you don’t have one, you can use track $index, but a unique ID is preferred when possible.
The @empty block shows what to display when the array is empty. What used to require a separate *ngIf is now in one neat place.
Inside @for, the following context variables are also available.
$index— current index (starts at 0)$first,$last— whether it’s the first/last item$even,$odd— whether the index is even/odd
@for (post of posts; track post.id; let i = $index, isFirst = $first) {
<li [class.first]="isFirst">{{ i + 1 }}. {{ post.title }}</li>
}@switch — multiple branches #
When a value branches into multiple cases, @switch fits well.
@switch (role) {
@case ('admin') {
<span class="badge admin">Admin</span>
}
@case ('editor') {
<span class="badge editor">Editor</span>
}
@default {
<span class="badge">Member</span>
}
}Unlike JavaScript’s switch, no break is needed. Only the matching @case runs, and @default runs if nothing matches.
*ngIf and *ngFor required importing CommonModule or the individual directives, but @if/@for/@switch are handled directly by the template compiler — no need to add anything to the imports array. One layer of boilerplate gone.The old structural directives — *ngIf, *ngFor #
In new projects you use the new control flow, but you’ll still see the old asterisk (*) directives often in existing projects and older examples.
<p *ngIf="user; else login">Welcome, {{ user.name }}</p>
<ng-template #login>
<p>You need to log in.</p>
</ng-template>
<ul>
<li *ngFor="let post of posts; trackBy: trackById">{{ post.title }}</li>
</ul>*ngFor required defining a separate trackBy tracking function, and forgetting it would cause performance problems where the entire DOM was redrawn on every render. That’s exactly why the new @for makes track mandatory syntax. You should be able to read the old code when you encounter it, but for new code, the new control flow is recommended. An official migration tool (ng generate @angular/core:control-flow) is also provided.
Attribute directives — ngClass, ngStyle #
When you change an element’s appearance based on a condition, use attribute directives. The two used most often are ngClass and ngStyle.
<a
[ngClass]="{ active: isActive, disabled: isDisabled }"
[ngStyle]="{ color: textColor, 'font-size.px': fontSize }">
Menu
</a>When you pass an object to [ngClass], the keys are class names and the values are whether to apply them (true/false). In the example above, active is added when isActive is true and removed when it’s false.
For simple cases, [class.className] or [style.property] binding is more concise.
<a [class.active]="isActive" [style.color]="textColor">Menu</a>Use [class.X]/[style.X] for simple conditions and [ngClass]/[ngStyle] when handling multiple classes at once. To use these two directives, add CommonModule to the component’s imports.
Creating a custom Directive #
You can also write your own directive to give an element behavior. Scaffold one with the CLI.
ng generate directive highlightLet’s add logic to change the background color on mouse hover.
import { Directive, ElementRef, HostListener, input } from '@angular/core';
@Directive({
selector: '[appHighlight]',
standalone: true,
})
export class HighlightDirective {
color = input<string>('#fffbcc');
constructor(private el: ElementRef<HTMLElement>) {}
@HostListener('mouseenter')
onMouseEnter() {
this.el.nativeElement.style.backgroundColor = this.color();
}
@HostListener('mouseleave')
onMouseLeave() {
this.el.nativeElement.style.backgroundColor = '';
}
}The key points:
selector: '[appHighlight]'— a selector wrapped in brackets is an attribute selector. It means it’s used as an attribute, like<p appHighlight>.ElementRef— the gateway to the host element the directive is attached to. WithnativeElement, you handle the actual DOM element.@HostListener— a decorator that subscribes to events on the host element.input<string>('#fffbcc')— directives can receive values viainput()just like components.
You use it like a regular attribute.
<p appHighlight>Hover over me (default yellow)</p>
<p appHighlight color="#cce5ff">This one's blue</p>Of course, you could do this much with CSS :hover alone, but the directive shines when dynamic logic kicks in — like “change the hover color based on a condition” or “only apply hover effects for certain permissions.”
What is a Pipe #
A Pipe is a small function inside a template that transforms data for display. The syntax resembles the Unix shell pipe.
{{ value | pipeName }}
{{ value | pipeName:arg1:arg2 }}
{{ value | pipeA | pipeB }}The value on the left of | is passed to the pipe on the right, and the result is passed to the next pipe. The key point is that it doesn’t touch the component’s data itself — it only changes how it appears on the screen.
Built-in Pipes #
Let’s go over the most commonly used of Angular’s built-in pipes.
<!-- Date format -->
<p>{{ today | date:'yyyy-MM-dd HH:mm' }}</p>
<!-- Currency -->
<p>{{ price | currency:'KRW':'symbol':'1.0-0' }}</p>
<!-- Percent -->
<p>{{ ratio | percent:'1.0-1' }}</p>
<!-- Case -->
<p>{{ name | uppercase }}</p>
<p>{{ name | lowercase }}</p>
<!-- Object to JSON string (for debugging) -->
<pre>{{ user | json }}</pre>
<!-- Number with decimals -->
<p>{{ score | number:'1.2-2' }}</p>
<!-- Slice -->
<p>{{ longText | slice:0:50 }}...</p>Among these, the one especially worth knowing is the async pipe.
@for (post of posts$ | async; track post.id) {
<li>{{ post.title }}</li>
}The async pipe takes an RxJS Observable or Promise, subscribes automatically, displays the value when it arrives, and unsubscribes automatically when the component is destroyed. You don’t have to manage subscribe() / unsubscribe() manually, making it the standard pattern for safely handling the RxJS flow briefly mentioned in #1 inside templates.
Pipes like date, currency, percent, number, and slice live in CommonModule, and so does async.
Creating a custom Pipe #
Let’s write a pipe ourselves. A truncate pipe that cuts a long string and adds an ellipsis.
ng generate pipe truncateimport { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'truncate',
standalone: true,
})
export class TruncatePipe implements PipeTransform {
transform(value: string, limit = 30, ellipsis = '...'): string {
if (!value) return '';
return value.length > limit ? value.slice(0, limit) + ellipsis : value;
}
}The key is the transform() method of the PipeTransform interface. The first parameter is the value on the left of |, and the rest are the arguments passed with :.
You use it like this.
@for (post of posts; track post.id) {
<li>
<h3>{{ post.title | truncate:20 }}</h3>
<p>{{ post.body | truncate:100:' ...(read more)' }}</p>
</li>
}Of course, you have to add it to the component with imports: [TruncatePipe].
Pure vs impure Pipes #
The @Pipe decorator has a pure option, which defaults to true. A pure pipe only recomputes when its input changes, so it’s very efficient. For the same input, it returns the cached result.
An impure pipe with pure: false runs again on every change-detection cycle. It’s used for special cases — like when items inside an array change but the reference stays the same, and you still need to reflect the new result — but it has a performance cost. When possible, it’s safer to swap the input itself with a new reference and stick with a pure pipe.
pure: true) is enough for almost every pipe, and the moments you need impure are rare.Recap #
This post covered two tools that broaden template expressiveness — Directives and Pipes. To wrap up the key points:
- The new control flow
@if/@for/@switchis the standard for branching and looping (Angular 17+) - The
trackexpression is required in@for - Simple class/style:
[class.X]/[style.X]. Multiple at once:[ngClass]/[ngStyle] - Custom directives add behavior to the host element with
@Directive+ElementRef+@HostListener - A pipe transforms data inside a template with
value | pipe:arg - The
asyncpipe is the most useful built-in — it auto-subscribes and auto-unsubscribes Observables/Promises - Custom pipes are written via the
transform()method ofPipeTransform
By this point, you have just about all the tools you need to draw a screen inside a component. But once a component starts holding its own data, calling APIs directly, and taking on business logic on top of all that, it bloats fast. The right place to separate and house those responsibilities is the Service.
In the next post, “Angular Basics #5: Service and Dependency Injection,” we’ll cover how to write a service class and how to connect it to a component using dependency injection — one of Angular’s strongest weapons.