Refreshing the fundamentals
What exactly is a directive?
A directive is a class annotated with the @Directive decorator. It enables you to define custom behavior and structure within your Angular HTML templates, aiming to extend the functionality of HTML elements by attaching custom logic. This can involve adding new elements, modifying existing ones, or altering an element's behavior.
What kinds of directives exist?
-
Structural — these alter the DOM's structure and are identified by an asterisk (*) prefix, such as
*ngIf,*ngFor, or*ngSwitch. -
Attribute — these modify the behavior or appearance of a specific component or HTML element, with examples like
ngClass,ngStyle, orngModel.
Understanding the scenario requirements
Envision an Angular application serving multiple user roles, such as editor and viewer. The management has requested that interactive controls be turned off and that certain parts of the interface become inaccessible for users with the viewer role. This introduces new specifications:
- the ability to render specific page elements exclusively for non-viewers,
- the ability to disable interactive components, like buttons or dropdowns, for users with the viewer role.
Reconsider relying on *ngIf everywhere
Naturally, *ngIf is the go-to method for hiding elements, but applying it directly here would be inefficient and prone to errors. Why? Examine the code snippets provided:
export const enum UserRole {
Editor = 'editor',
Viewer = 'viewer',
}
export interface User {
id: number;
firstName: string;
lastName: string;
role: UserRole;
}
@Injectable({
providedIn: 'root',
})
export class UserService {
readonly #currentUser$ = new Subject<User>();
readonly isViewer$ = this.#currentUser$.pipe(
map(user => user.role === UserRole.Viewer),
startWith(true),
);
getCurrentUser = (): Observable<User> => this.#currentUser$.asObservable();
setCurrentUser = (user: User): void => {
this.#currentUser$.next(user);
};
}
@Component({
selector: 'app-sample',
template: `
<mat-list *ngIf="!(userService.isViewer$ | async)" role="list">
<mat-list-item role="listitem">Setting 1</mat-list-item>
<mat-list-item role="listitem">Setting 2</mat-list-item>
</mat-list>
`,
})
export class SampleComponent {
constructor(readonly userService: UserService) {}
}
Here, a straightforward user service manages the logged-in user's data and role. To conceal the mat-list element in the component's template, you would inject UserService into its constructor and use the async pipe to subscribe to the isViewer$ observable. The mat-list would become visible only when isViewer$ emits false. At a glance, this appears acceptable, but:
-
What happens when this logic is repeated across dozens of components?
You'd need to inject
UserServiceinto each component and replicate the same*ngIfcondition across all templates. That strongly suggests an unwanted copy/paste approach. Also, consider the effort to update the condition in every single location. - What occurs if you overlooked the negation in the *ngIf condition? In that case, confidential or restricted data could become visible to the wrong user group.
Leveraging structural directives effectively
A robust approach to showing content solely to editors is to develop a custom structural directive.
@Directive({
selector: '[ifNotViewer]',
standalone: true,
hostDirectives: [NgIf, DestroyedDirective]
})
export class IfNotViewerDirective implements OnInit {
private readonly ngIfDirective = inject(NgIf);
private readonly destroyed$ = inject(DestroyedDirective).destroyed$;
constructor(private readonly userService: UserService) {}
ngOnInit(): void {
this.userService.isViewer$
.pipe(takeUntil(this.destroyed$))
.subscribe((isViewer) => {
this.ngIfDirective.ngIf = !isViewer;
});
}
}
The Directive Composition API simplifies the creation of some structural directives in Angular 15. For instance, IfNotViewerDirective includes two host directives: the familiar NgIf and DestroyedDirective (*). Within the ngOnInit hook, it subscribes to the isViewer$ observable. The emitted value directly determines the ngIf condition; when isViewer is false, the ngIf directive allows the content to be rendered.
For earlier Angular releases, you'd have to inject TemplateRef, which points to the template to be rendered when the condition is true, alongside ViewContainerRef, which offers the createEmbeddedView method for rendering and clear() for removal.
(*) The DestroyedDirective follows the approach proposed by Kristiyan Kostadinov.
Putting this directive into practice is straightforward; simply attach its selector to the desired element.
@Component({
selector: 'app-sample',
template: `
<mat-list *ifNotViewer role="list">
<mat-list-item role="listitem">Setting 1</mat-list-item>
<mat-list-item role="listitem">Setting 2</mat-list-item>
</mat-list>
`,
})
export class SampleComponent {}
Enhancing component behavior
The next challenge involved deactivating interactive elements, such as buttons or select menus, for users with read-only permissions. One option was injecting UserService into each component needing this feature and binding the isViewer$ value to the element's property. However, custom attribute directives offer a more elegant solution. Consider this code.
@Directive({
selector: '[disableIfViewer]',
standalone: true,
hostDirectives: [DestroyedDirective]
})
export class DisableIfViewerDirective implements OnInit {
private readonly destroyed$ = inject(DestroyedDirective).destroyed$;
constructor(private readonly userService: UserService,
@Optional() @Self() private readonly button: MatButton,
@Optional() @Self() private readonly select: MatSelect) {}
ngOnInit(): void {
this.userService.isViewer$
.pipe(takeUntil(this.destroyed$))
.subscribe((isViewer) => {
if (this.button) {
this.button.disabled = isViewer;
} else if (this.select) {
this.select.disabled = isViewer;
}
});
}
}
The DisableIfViewerDirective functions as an attribute directive that deactivates an element based on the isViewer$ observable from the injected UserService. Along with the service, you inject the interactive component to be disabled, such as a MatButton or MatSelect. If the button or select exists, it gets disabled when isViewer is true. This pattern is flexible; you can inject any component type or simply ElementRef to implement specific rules.
The following snippet shows its usage; for viewers, the second button and the select become inactive.
@Component({
selector: 'app-sample',
template: `
<button mat-button mat-raised-button>Always available</button>
<button disableIfViewer mat-button mat-raised-button>Only for editors!</button>
<mat-select disableIfViewer placeholder="Settings">
<mat-option value="'setting1'">Setting 1</mat-option>
<mat-option value="'setting2'">Setting 2</mat-option>
</mat-select>
`,
})
export class SampleComponent {}
Final takeaways
- Improved clarity — using a directive like ifNotViewer is often more descriptive than an embedded condition in *ngIf. It communicates the intent more directly and gives a clearer answer to the problem being solved.
- Reusability — directives can be shared across numerous components, while manually adding conditions to elements like ngIf or disabled would require extensive copy/pasting and future adjustments in multiple templates and components.
- Encapsulated logic — directives can encapsulate sophisticated logic and manage element insertion, removal, or behavior alterations smoothly.
- Fewer bugs — given the preceding advantages, custom directives tend to be less error-prone in practice.
I appreciate you reading this piece. Hopefully, it proved insightful and valuable. If you enjoyed it, feel free to follow me on Twitter at @pawelkubiakdev for further insights.
