Original cover photo by Ivan Bandura on Unsplash.
Welcome to the sixth entry in our series on Angular directives and their seemingly magical capabilities. Today, we shift focus to a less common yet highly frequent real-world scenario: embedding business logic directly into directives.
Introduction
Before diving in, let's quickly recap what we've accomplished so far and understand how today's use cases stand apart.
Previously, we built directives that performed specific, reusable, and practical tasks.
For instance, we created a directive that
- assessed password strength
- triggered custom events when the host element entered the viewport
- moved a template view into a different component
- rendered a loading indicator
- applied default input values to existing components
These examples share a common thread: each directive is broadly reusable and can be dropped into any project without modification, as none of them contain any business logic. However, in many real-world applications, the logic we want to reuse and share is precisely the business logic. So, what options do we have?
Using directives to handle permissions
Consider an app that uses permissions to control feature availability. For example, users might edit their profile only if granted the appropriate permission. We have a PermissionService exposing a hasPermission method that accepts a permission name and returns a boolean. In a component, we might use it like this:
@Component({
selector: 'app-profile',
template: `
<div *ngIf="hasPermission('edit-profile')">
<button (click)="editProfile()">Edit profile</button>
</div>
`
})
export class ProfileComponent {
constructor(private permissionService: PermissionService) {}
hasPermission(permissionName: string) {
return this.permissionService.hasPermission(permissionName);
}
editProfile() {
// ...
}
}
However, every time we need to verify a permission, we'd have to inject the service, potentially write a wrapper, call it from the template, or use another somewhat tedious approach. Furthermore, if the permission mechanism changes (like a new API for the service), we might face a significant refactor. And if we use an Observable-based state management solution like NgRx, the code gets even more complicated.
How can we simplify this?
Wouldn't it be convenient to write something like this instead:
@Component({
selector: 'app-profile',
template: `
<div *hasPermission="'edit-profile'">
<button (click)="editProfile()">Edit profile</button>
</div>
`
})
export class ProfileComponent {
editProfile() {
// ...
}
}
This way, we simply provide the permission name as an input to the directive, and it manages everything else. Let's see how to build this.
- First, create a directive that injects the
PermissionServiceand has an input property for the permission name. - Next, add
ngIfas a HostDirective to handle template visibility. - Finally, inject the
NgIfdirective reference and pass the resulting boolean to it.
Let's implement it:
@Directive({
selector: '[hasPermission]',
standalone: true,
hostDirectives: [NgIf],
})
export class HasPermissionDirective {
private readonly permissionService = inject(PermissionService);
private readonly ngIfRef = inject(NgIf);
@Input()
set hasPermission(permissionName: string) {
// we can use any other approach here
this.ngIfRef.ngIf = this.permissionService.hasPermission(
permissionName,
);
}
}
Our directive now works almost flawlessly. What's missing? We need to support the scenario where the user lacks permission. We can achieve this by adding an else template to our directive:
@Directive({
selector: '[hasPermission]',
standalone: true,
hostDirectives: [NgIf],
})
export class HasPermissionDirective {
private readonly permissionService = inject(PermissionService);
private readonly ngIfRef = inject(NgIf);
@Input()
set hasPermission(permissionName: string) {
this.ngIfRef.ngIf = this.permissionService.hasPermission(
permissionName,
);
}
// the improtant part
@Input()
set hasPermissionElse(template: TemplateRef<any>) {
this.ngIfRef.ngIfElse = template;
}
}
At this point, we're essentially executing the business logic and forwarding the outcome to the NgIf directive. Here's a live example:
Using directives to handle shared data in components
Suppose we're using a UI library that provides dropdown components:
@Component({
selector: 'app-profile',
template: `
<div>
<third-party-dropdown
[items]="dropdownItems"></third-party-dropdown>
</div>
`
})
export class ProfileComponent {
dropdownItems = [
{
label: 'Item 1',
value: 1
},
{
label: 'Item 2',
value: 2
}
];
}
Often, we need to supply the same set of options to several dropdowns. For instance, the list of permissions we discussed earlier might be needed in many places:
@Component({
selector: 'app-profile',
template: `
<div>
<third-party-dropdown
[items]="permissions"></third-party-dropdown>
</div>
<div>
`
})
export class SomeComponent {
permissionService = inject(PermissionService);
permissions = this.permissionService.getPermissions();
}
We'd likely end up duplicating this code across the app. How do we make this more reusable? One approach is to create a wrapper component that accepts the item list as an input:
<app-wrapper-around-third-party-dropdown
[items]="permissions"></app-wrapper-around-third-party-dropdown>
However, that approach introduces its own set of issues:
- CSS encapsulation challenges
- Needing to forward all of the third-party dropdown's inputs and outputs
- Increased template complexity
What's the alternative? We can create a directive that injects the list of items into the third-party dropdown:
@Directive({
selector: 'third-party-dropdown[permissionList]',
standalone: true,
})
export class PermissionsDropdownDirective implements OnInit {
thirdPartyDropdown = inject(ThirdPartyDropdown);
permissionService = inject(PermissionService);
ngOnInit() {
this.thirdPartyDropdown.items =
this.permissionService.getPermissions();
}
}
Now we can use it like this:
<third-party-dropdown permissionList></third-party-dropdown>
Done! Our template stays clean, the third-party-dropdown remains unchanged, and we've added reusable functionality to it.
Here's the example with a live preview:
Conclusion
We've covered a vast range of what directives can do for us. Even though there's more to explore, we've covered enough for projects of varying sizes, so this series is winding down. In the upcoming final article, we'll dive into directive selectors in detail—what options we have, how to combine them, and common pitfalls or restrictions. See you there!
