There are many articles online about Angular best practices, or even best practices for components specifically. Of course, components are the most important building blocks of the framework, but we know that directives are almost as important, and when it comes to them, there are also certain patterns of code that are preferable to other.
So, today, let's take a look at directive best practices and figure out how to write the cleanest possible code to enrich our templates.
Alias inputs that match directive selectors
A popular pattern with directives that comes up pretty often is a directive that has exactly one input and does a specific thing. Very often developers will give that input the same name as the directive's attribute selector to simplify the template where it can be used. Now, let's take a look at this simple directive that displays a tooltip:
@Directive({
selector: '[appTooltip]',
})
export class TooltipDirective implements AfterViewInit {
// input for custom text
appTooltip = input.required<string>();
ngAfterViewInit() {
// code that adds the tooltip
}
}
This naming allows us to use the tooltip in a very simple way in a template:
<div appTooltip="Some text">Content</div>
However, the directive code suffers a bit, as appTooltip
is not a very descriptive name, which can confuse someone who is trying to understand the code. To keep both the template and the directive code clean, we can instead alias the input to the attribute selector:
@Directive({
selector: '[appTooltip]',
})
export class TooltipDirective implements AfterViewInit {
// aliased input for custom text
tooltipText = input.required<string>({alias: 'appTooltip'});
ngAfterViewInit() {
// code that adds the tooltip
}
}
This way, we can still use the directive in the template the same way as before, but the directive code is now somewhat improved.
Utilize more than just attribute selectors
As we already touched directive selectors, we can talk a bit more about them. An (anti)pattern that is very common among Angular developers is making up a custom attribute name for every directive. However, as we shall see, this is not always necessary.
Consider this directive, that, when applied to an input, validates an email address:
@Directive({
selector: '[appEmailValidator]',
})
export class EmailDirective implements Validator {
validate(control: AbstractControl) {
// validate with some custom regex
return control.value.match(/.+@.+\..+/) ? null : {
email: true,
};
}
}
Then we can apply it whenever we use an email input:
<input type="email" appEmailValidator>
However, directive selectors are way more versatile than just attribute selectors, and there is no need to constantly put the appEmailValidator
attribute on inputs, because we can simply target all inputs of type "email":
@Directive({
selector: 'input[type=email]',
})
export class EmailDirective implements Validator {...}
Then, any time we create an input of type "email", the validator will get automatically applied (given that we imported the directive into the component).
Use host
to apply dynamic styles
Another common use case for directives is applying some custom styles to the host element depending on some conditions. Often, developers do it "manually". Let's take a look at this directive which checks blocks the UI for an element if the user has no certain permission:
@Directive({
selector: '[appBlockUI]',
})
export class BlockUIDirective implements OnInit {
private readonly permissionsService = inject(PermissionService);
private readonly elementRef = inject(ElementRef);
permissionName = input.required<string>({alias: 'appBlockUI'});
ngAfterViewInit() {
this.permissionsService.getPermissions()
.subscribe(permissions => {
if (!permissions.has(this.permissionName())) {
this.elementRef.nativeElement.style.opacity = '0.5';
this.elementRef.nativeElement.style.pointerEvents = 'none';
} else {
this.elementRef.nativeElement.style.opacity = '1';
this.elementRef.nativeElement.style.pointerEvents = 'auto';
}
});
}
}
Here, while the directive "does the job", the code inside it reads as very "imperative", we give commands and accessing the DOM directly. That's not what we usually do in Angular, and in this case, it is full avoidable. We can instead utilize signals and RxJS interop, and rely on the host
metadata property to bind the results from the service to the DOM element:
@Directive({
selector: '[appBlockUI]',
host: {
'[style.pointerEvents]': 'styles().pointerEvents',
'[style.opacity]': 'styles().opacity',
},
})
export class BlockUIDirective {
private readonly permissionsService = inject(PermissionService);
permissionName = input.required<string>({alias: 'appBlockUI'});
permissions = toSignal(this.permissionsService.getPermissions());
styles = computed(() => {
const hasPermission = this.permissions().has(this.permissionName());
return ({
pointerEvents: hasPermission ? 'auto' : 'none',
opacity: hasPermission ? 1 : 0.5,
});
});
}
As we can see, this becomes a lot more readable and maintainable. In general, directives have the potential to be even more declarative in code style than components, making it super important to keep them clean and easy to understand.
Do not use boolean inputs to apply the directive conditionally
Sometimes, we might write a selector that neatly matches all of the elements we want, but also catches some scenarios where we don't really want the directive to be applied. Consider this example:
@Directive({
selector: '[routerLink]',
host: {
'(mouseover)': 'showPreview()',
'(mouseout)': 'hidePreview()',
},
})
export class PreviewLinkDirective {
routerLink = input.required<string>();
showPreview() {
// some logic that generates a preview on hover
}
hidePreview() {
// some logic that hides the preview on mouseout
}
}
Here, we have a directive that, when applied to an element, shows a preview of the link when the user hovers over it, and hides it when the user moves the mouse away. However, sometimes we might just not want to show the preview, for instance, for some internal links, like a login page, the preview just doesn't make sense. We could do something like this:
@Directive({
selector: '[routerLink]',
host: {
'(mouseover)': 'showPreview()',
'(mouseout)': 'hidePreview()',
},
})
export class PreviewLinkDirective {
routerLink = input.required<string>();
showPreview = input(true);
showPreview() {
if (this.showPreview()) {
// some logic that generates a preview on hover
}
}
hidePreview() {
if (this.showPreview()) {
// some logic that hides the preview on mouseout
}
}
}
This way, we can pass a false value to the directive to disable the preview, and the logic will not be applied. This approach, sadly, has two downsides
- The directive becomes (as we will see) unnecessarily verbose.
- The directive still gets applied, so if a future developer adds a new
host
listener and forgets to use theshowPreview
input, a bug will be possibly introduced.
So, how can we address this? Well, we can use the :not()
CSS selector, which directive selectors support, to avoid applying the directive at all in certain cases. Here's how we can do it:
@Directive({
selector: '[routerLink]:not([noPreview])',
host: {
'(mouseover)': 'showPreview()',
'(mouseout)': 'hidePreview()',
},
})
export class PreviewLinkDirective {
routerLink = input.required<string>();
showPreview() {
// some logic that generates a preview on hover
}
hidePreview() {
// some logic that hides the preview on mouseout
}
}
Then, in the template, whenever we want a certain link to not have a preview, we can just add that attribute:
<a routerLink="some-link" noPreview>Some link</a>
Now, the directive will not be applied to that link at all, and there is no need to introduce conditional logic to the directive code.
Important to know
While in 90% cases the approach we just described is the best practice, it is not completely infallible, and sometimes boolean inputs enabling/disabling the directive functionality are still necessary.
To understand why, we need to first understand how Angular directives actually work. Hint: it is a bit more complex than you might think.
From outside, it might seem that when we write a selector, the DOM is being searched for matched elements, and, when found, the directive is applied to them. However, this is not the case at all.
In reality Angular directives are applied to DOM elements at compile-time, not during run-time. This means that when we run ng serve
or ng build
, and Angular actually builds our application and compiles our templates to executable JavaScript commands, it takes into consideration the directives that a given component imports, and, if a corresponding element is found, the directive is applied to it.
While this might seem like not that big of a deal, it has massive implications. First and foremost, it means we cannot apply directives dynamically. Even if we use Renderer2
to add an attribute to an element that perfectly matches with some directive, there is no mechanism in Angular that will see that change and apply the directive to the element at runtime.
This also means that when we used the negation selector (:not(noPreview)
in the previous example), we excluded that certain element from having that directive forever. There is no way to dynamically apply it back.
In most of scenarios where this approach is used, we really want to exclude an element for good; maybe the directive selector is fine, but too broad, and some particular elements don't need that, so we use selector negation.
However, if the execution of the directive code is dependant on an external condition, and we want the logic re-executed when that condition changes, we are completely justified in using a boolean input.
Try to avoid using directives for custom events
Sometimes, we want to create a directive that will trigger a custom event on the host element. For example, we might be building a UX that utilizes keyboard shortcuts like Ctrl + Click
or Ctrl + Enter
a lot. We might be tempted to write a directive that will listen to those events and trigger a custom event on the host element, like this:
@Directive({
selector: '[appCtrlClick]',
host: {
'(click)': 'handleClick($event)',
},
})
export class CtrlClickDirective {
appCtrlClick = output<MouseEvent>();
handleClick(event: MouseEvent) {
if (event.ctrlKey) {
this.appCtrlClick.emit(event);
}
}
}
We can then use it in a template somewhere:
<button (appCtrlClick)="log($event)">Content</button>
Of course, it works pretty well, but has a few downsides:
- The directive is not very generic, for other events, we would need to either create another directive, or complicate this one.
- We need to import it everywhere we use.
- Angular has a conventional way of dealing with custom events, and we can utilize it.
Instead of using the directive here, we can use the EventManagerPlugin
and define a custom plugin for Ctrl + Click
events:
export class CtrClickPlugin extends EventManagerPlugin {
override supports(eventName: string): boolean {
// catch all ctrl.click events
return eventName === 'ctrl.click';
}
override addEventListener(
element: HTMLElement,
eventName: string,
originalHandler: EventListener
) {
// wrap the original handler with Ctrl check
const handler = (event: MouseEvent) => {
if (event.ctrlKey) {
originalHandler(event);
}
};
element.addEventListener('click', handler);
return () => {
// remove the listener
element.removeEventListener('click', handler);
};
}
}
Then, we have to provide the plugin in the application configuration:
export const appConfig: ApplicationConfig = {
providers: [
{
provide: EVENT_MANAGER_PLUGINS,
useClass: CtrClickPlugin,
multi: true,
},
],
}
Then, we can freely use the event in any component template we wish:
<button (ctrl.click)="log($event)">Content</button>
This makes it fully reusable, and easier to customize (we can add multiple event plugins, or different functions to choose from depending on the event name to handle Ctrl + Enter
, Ctrl + Right Click
and so on).
Conclusion
Directives are a powerful and versatile tool, and as with any other tool, they can be easily abused. However, with the ruleset that this article provides, we hope the readers will enjoy righting simpler, less error-prone and easier-to-understand directives to utilize their full power.
Small promotion
As you see, in most examples discussed, we use signals, signal inputs/outputs, and so on, all the things that are quite new to Angular. The recent upheaval in Angular has caused many developers to be confused about what solutions to chose, how to implement them, and how to migrate their existing codebases to the most recent features. Thankfully, I have a response to this concern: very soon, my very first book is going into print!
It is called "Modern Angular" and it is a comprehensive guide to all the new amazing features we got in recent versions (v14-v18), including standalone, improved inputs, signals (of course!), better RxJS interoperability, SSR, and much more. If this interested you, you can find it here.
The book is now in the copy-editing phase with a release scheduled shortly, so it is currently in Early Access, with all the 10 chapters already available online. If you want to keep yourself updated on the print release, you can follow me on Twitter or LinkedIn, where I will be posting whenever there are news or promotions available.
P.S. Hey! Check out chapter 4 of my book if you're curious Angular directives so you can learn about the power of HostDirective
-s, and chapters 6-7 to dive deep into signals ;)