Introduction
I have been saying this for a looong while: Directives are the most underutilized part of Angular. They provide a powerful toolset for doing magic in templates, and yet in most projects, it is used in the most common, "attribute directives that do some limited business logic" style.
The next most underutilized thing is dependency injection. It is an awesome concept for building reusable stuff, yet 95% of DI usage in Angular is for services.
I have written a bunch of articles on both topics, and here is a list of them. I recommend you read those before diving into this one, although that is not a requirement:
In this series we will dive deeper and explore how both of those concepts can be utilized (often together) to significantly simplify our templates. We will do so on a use case examples, in a step-by-step format.
Note: I do not choose these examples specifically because they are very common or very useful; often, solutions in form of third-party libraries exist; these examples are just good from the learning perspective, as they allow to showcase a lot of concepts in a relatively small amount of code.
Disclaimer: in this article, in most cases we are going to use legacy decorator
@Input
-s, as most developers are familiar with those. However, you can easily sibstitute them with signal inputs. One example will specifically use signal inputs to showcase how they make working with directives easier.
So, without further ado, let's get started!
Building a password strength meter
A functionality that exists in lots of modern-day web apps is checking for a user's password strength. Of course, solutions for this exist in the open. but let's build something of our own, and in a way that it would be really customizable.
Let's start with the simplest possible scenario: we add some class on the input element so it can be shown visually:
type PasswordStrength = 'weak' | 'medium' | 'strong';
@Directive({
selector: '[appPasswordStrength]',
standalone: true,
host: {
'(input)': 'onInput($event)',
},
})
export class PasswordStrengthDirective {
private readonly el = inject(ElementRef);
onInput(event: InputEvent) {
const input = event.target as HTMLInputElement;
const value = input.value;
const strength = this.evaluatePasswordStrength(value);
this.el.nativeElement.classList.add(
`password-strength-${strength}`
);
}
evaluatePasswordStrength(password: string): PasswordStrength {
if (password.length < 6) {
return 'weak';
} else if (password.length < 10) {
return 'medium';
}
return 'strong';
}
}
And then we can use it in the template like this:
<input type="password" appPasswordStrength>
Fairly simple. (Ignore the simplicity of the logic behind evaluation; it is really irrelevant and we can put any logic there - our aim is to make this directive maximally customizable).
But now we have several issues:
- Why the selector? If we forget to put the
[appPasswordStrength]
attribute, the directive will not work. Can we make it work automatically on all password inputs? - What if we need logic that is not just adding a class, but also adding some text to the DOM, for example? Can we make the directive just inform the template about the strength of the password and then let it handle in a custom way?
- What about customizing the evaluator function? Can we make it so that the developer (using the directive) can provide their own function to evaluate the password strength?
- If the developer provides the logic for evaluation, can we make it possible to both provide the logic application-wide, from one place, and customize it on a per-input basis?
Let's explore all of these issues and improve our directive. Let's start with the first, simplest one:
@Directive({
selector: 'input[type="password"]',
standalone: true,
})
// directive implementation
Now we can just drop the attribute selector:
<input type="password">
Now it will work automatically. But what if, in some cases, we want to ignore the checking? We can add an input for that:
@Directive({
selector: 'input[type="password"]',
standalone: true,
host: {
'(input)': 'onInput($event)',
},
})
export class PasswordStrengthDirective {
@Input() noStrengthCheck = false;
private readonly el: inject(ElementRef);
onInput(event: InputEvent) {
if (this.noStrengthCheck) {
return;
}
// logic goes here
}
// the other methods
}
And then we can use it like this:
<input type="password" [noStrengthCheck]="true">
Cool, the first improvement is done. Let's now make it so the component, rather than add a class, just informs the template about the strength of the password and lets it do the job itself. We could do that by adding an output, but that would mean more boilerplate for the developers in the template to capture the strength in a variable before using it. So instead we will use exportAs
to work with the directive instance directly:
@Directive({
selector: 'input[type="password"]',
standalone: true,
exportAs: 'passwordStrength',
host: {
'(input)': 'onInput($event)',
},
})
export class PasswordStrengthDirective {
@Input() noStrengthCheck = false;
// property to capture in the template
strength: PasswordStrength = 'weak';
// no need for ElementRef anymore
onInput(event: InputEvent) {
if (this.noStrengthCheck) {
return;
}
this.strength = this.evaluatePasswordStrength(value);
}
evaluatePasswordStrength(password: string): PasswordStrength {
if (password.length < 6) {
return 'weak';
} else if (password.length < 10) {
return 'medium';
}
return 'strong';
}
}
Now we only write the strength itself to a property to let the developer capture it in the template. Here is how it is done:
<input type="password" #evaluator="passwordStrength">
@switch (evaluator.strength) {
@case ('weak') {
<div>Weak password</div>
}
@case ('medium') {
<div>Medium password</div>
}
@case ('strong') {
<div>Strong password</div>
}
}
We use exportAs
to capture the directive instance in a template variable, and then we can use it to access the strength property. You can read more about it in the official documentation.
Now, let's make it so the developer can provide their own logic for evaluating the password strength. Again, we could do it using a standard Input
property, but that would mean we would have to provide that function every time we have a password input, and that is cumbersome and error-prone - easy to forget. So instead we will use an InjectionToken
together with a small helper function to provide the logic application-wide:
type PasswordEvaluatorFn = (password: string) => PasswordStrength;
export const EVALUATOR_FN_TOKEN = new InjectionToken<
PasswordEvaluatorFn
>(
'PasswordEvaluatorFn',
);
export function providePasswordEvaluatorFn(
evaluatorFn: PasswordEvaluatorFn,
) {
return [{
provide: evaluatorFnToken,
useValue: evaluatorFn,
}];
}
@Directive({
// eslint-disable-next-line @angular-eslint/directive-selector
selector: 'input[type="password"]',
exportAs: 'passwordEvaluator',
standalone: true,
host: {
'(input)': 'onInput($event)',
},
})
export class PasswordEvaluatorDirective {
strength: PasswordStrength = 'weak';
@Input() evaluatorFn = inject(evaluatorFnToken);
@Input() noStrengthCheck = false;
onInput(event: InputEvent) {
if (this.noStrengthCheck) {
return;
}
const input = event.target as HTMLInputElement;
const value = input.value;
this.strength = this.evaluatorFn(value);
}
}
Now we can just provide a custom evaluation function application-wide:
export function customPasswordEvaluator(password: string) {
if (password.length < 6) {
return 'weak';
} else if (password.length < 10) {
return 'medium';
}
return 'strong';
}
bootstrapApplication(AppComponent, {
providers: [
providePasswordEvaluatorFn(customPasswordEvaluator),
],
// the rest of the application
});
And use it as we please.
But here comes a problem: what if the user does not provide a custom evaluator function? We can make it so the directive throws an error if it is not provided, but that might not be the best solution. So, let's instead make the directive use a default evaluator function if the user has not provided one. But, right now, if there is no custom function provided, the dependency injection mechanism will throw a NullInjectorError
error. Here, the optional
flag comes to the rescue:
@Directive({
//...
})
export class PasswordEvaluatorDirective {
//...
evaluatorFn = inject(evaluatorFnToken, { optional: true });
//...
}
Now the inject
function will return null
instead of throwing an error if the token is not provided. We can use that to provide a default evaluator function:
export const defaultEvaluatorFn: PasswordEvaluatorFn = (
password: string,
): PasswordStrength => {
if (password.length < 6) {
return 'weak';
} else if (password.length < 10) {
return 'medium';
}
return 'strong';
}
@Directive({
//...
})
export class PasswordEvaluatorDirective {
//...
evaluatorFn = inject(
evaluatorFnToken,
{ optional: true },
) ?? defaultEvaluatorFn;
//...
}
So now, if the user is satisfied with the default evaluator function, they don't have to provide anything. But if they want to provide their own, they can do that as well, both at the component level and application-wide.
So now, that last question remains: how to provide a custom evaluator on an input basis? Meaning, we can have several password inputs in the same component, but we want some of them to work differently. Because of how the inject
function works, we can just decorate our evaluatorFn
with @Input
and it will work:
@Directive({
//...
})
export class PasswordEvaluatorDirective {
//...
@Input() evaluatorFn = inject(
evaluatorFnToken,
{ optional: true },
) ?? defaultEvaluatorFn;
//...
}
And now we can use it like this:
<input type="password"
#evaluator="passwordEvaluator"
[evaluatorFn]="myEvaluatorFn"/>
Here is the final version of our component, with a live demo:
What's next?
In this part, we have explored how to use InjectionToken
to provide custom logic to a directive, how to use an exported instance of the directive, and use a custom selector for matching. In the next one, we will dive into using structural directives and performing advanced DOM manipulations.
We are going to explore structural directives and how we can make components and directives interoperate and reduce clutter in our .html
files even further.
Let's build a loader component!
The Use Case
Imagine the following scenario: we have a component that loads some data, and we want to show a loading indicator while the data is being fetched. The following criteria should be met:
- We should be able to wrap any template inside our loader component, and it will display a spinner when needed
- The component should receive an input property indicating whether the data is being loaded or not
- The template should be covered by an overlay, so that the user cannot interact (and potentially trigger other HTTP calls) while the data is being loaded
Here is a pretty simple implementation:
@Component({
selector: 'app-loader',
template: `
<div class="loading-container">
<ng-content/>
@if (loading) {
<div class="blocker">
<p-progressSpinner/>
</div>
}
</div>`,
standalone: true,
styles: [
`
.loading-container {
position: relative;
}
.blocker {
background-color: black;
position: absolute;
top: 0;
z-index: 9999;
width: 100%;
height: 100%;
opacity: 0.4;
}
`,
],
imports: [NgIf, ProgressSpinnerModule],
})
export class LoaderComponent {
@Input() loading = false;
}
Note: I am using PrimeNG for the examples in this article, but you can easily reuse them with any other implementation
So, here we just project any content that we receive into ng-content
and the rest is some simple CSS + PrimeNG ProgressSpinner
component. The loading
input property is used to toggle the spinner on and off.
Now we can use it in the template as follows:
<app-loader [loading]="loading">
<p>Some content</p>
</app-loader>
Wait, I thought this article is about directives?
Well, good news: it is about directives. But what's the problem with the component? Well, the example of its usage we saw was quite optimistic: real-life scenarios usually are not that simple. Consider this piece of template:
<app-loader [loading]="loading">
<div class="p-grid">
<div class="p-col-12">
<p>Some content</p>
</div>
<app-loader [loading]="otherLoading">
<div class="p-col-12">
<p>Some other content</p>
<app-loader [loading]="evenMoreLoading">
<div class="p-col-12">
<p>Even more content</p>
</div>
</app-loader>
</div>
</app-loader>
</div>
</app-loader>
Now, here, when we have some nested elements, and the template keeps unnecessarily growing, adding more indentation levels, more closing tags, and so on, and so on. What I personally would really love to be able to do is the following:
<p *appLoading="loading">Some content</p>
But how can we achieve this? Well, we need a directive that does the following things:
- Creates a
LoaderComponent
instance dynamically - Somehow projects the nested template into it
- Keeps them in sync - when the
loading
input property changes, the directive should update theLoaderComponent
instance accordingly - Render the whole stuff
Let's dive into it!
Structural Directives
Structural directives are really cool, because they allow us to reference some templates via TemplateRef
and do all sorts of magic with it.
Also, we can use the ViewContainerRef
to create components dynamically. What will be left for us is to project the template into the component, And yes, this is possible! Let's start simple:
@Directive({
selector: '[appLoading]',
standalone: true,
})
export class LoaderDirective {
private readonly templateRef = inject(TemplateRef);
private readonly vcRef = inject(ViewContainerRef);
@Input() appLoading = false;
templateView: EmbeddedViewRef<any>;
loaderRef: ComponentRef<LoaderComponent>;
}
Here we injected the things we need (TemplateRef
and ViewContainerRef
), added a loading
input, naturally, and created two properties: templateView
and loaderRef
. The first one will be used to store the reference to the template that we get, and the second one will be used to store the reference to the ComponentRef
instance that we are going to create - we have to store both.
Next, let's do some initial heavy lifting to set the whole thing up:
@Directive({
selector: '[appLoading]',
standalone: true,
})
export class LoaderDirective implements OnInit {
private readonly templateRef = inject(TemplateRef);
private readonly vcRef = inject(ViewContainerRef);
@Input() appLoading = false;
templateView: EmbeddedViewRef<any>;
loaderRef: ComponentRef<LoaderComponent>;
ngOnInit() {
this.templateView = this.templateRef.createEmbeddedView({});
this.loaderRef = this.vcRef.createComponent(LoaderComponent, {
injector: this.vcRef.injector,
projectableNodes: [this.templateView.rootNodes],
});
this.loaderRef.setInput('loading', this.appLoading);
}
}
Here, our ngOnInit
lifecycle method does four things:
- Make the template into an embedded view, so we can render it dynamically
- Create a
LoaderComponent
instance - Project the template into the
LoaderComponent
instance viaprojectableNodes
- here is where the magic is happening! - Set the
loading
input property on theLoaderComponent
instance
Now, this way it will kinda work, but we need two more things to make it work properly:
- We need to update the
LoaderComponent
instance when theloading
input property changes - We need to ensure change detection still works on the projected template despite it being "detached" from the parent view and projected into a new component. We will use
ngDoCheck
for this
Let's finalize the implementation:
@Directive({
selector: '[appLoading]',
standalone: true,
})
export class LoaderDirective implements OnInit, DoCheck, OnChanges {
private readonly templateRef = inject(TemplateRef);
private readonly vcRef = inject(ViewContainerRef);
@Input() appLoading = false;
templateView: EmbeddedViewRef<any>;
loaderRef: ComponentRef<LoaderComponent>;
ngOnInit() {
this.templateView = this.templateRef.createEmbeddedView({});
this.loaderRef = this.vcRef.createComponent(LoaderComponent, {
injector: this.vcRef.injector,
projectableNodes: [this.templateView.rootNodes],
});
this.loaderRef.setInput('loading', this.appLoading);
}
ngOnChanges() {
this.loaderRef?.setInput('loading', this.appLoading);
}
ngDoCheck() {
this.templateView?.detectChanges();
}
}
Those additions are fairly simple: when the loading
property of the component changes, we update the LoaderComponent
instance accordingly, and when the change detection runs for the directive instance, we also notify the child template in the ngDoCheck
lifecycle method via templateView.detectChanges()
. This is to avoid issues when a child component inside the template view uses the OnPush
strategy. If you are unfamiliar with how ngDoCheck
works or why it is used, you can read the official docs, or this tutorial.
Now we can simply use it in the template, even when we have multiple nested elements:
<p *appLoading="loading">
Some content
<span *appLoading="otherLoading">
Some other content
</span>
<p *appLoading="evenMoreLoading">
Even more content
</p>
</p>
And so, there is no nested template, no unnecessary indentation, and no unnecessary closing tags. It's just a simple directive that does the job.
You can view the full example with a live demo on StackBlitz:
Where do we go next?
As mentioned previously, I believe directives are very, very powerful, but sadly underused in the wider community. In the next part, we will explore using directives to hack into existing components.
In previous parts, we explored how to use directives to maintain and export local state in templates and reuse it, and how to use structural directives to simplify our templates. In this part, we are going to explore how we can use directives to hijack existing elements and components to add or modify functionality.
So, let's explore use cases.
Use case 1: Hijacking existing elements
Imagine the following scenario - we are maintaining a website that displays lots of content, and also has lots of internal pages. That content often contains links to external resources, and also obviously links for internal navigation between pages. Now, imagine, we want to (almost) always open external links in a new tab (for example, not to disrupt the user's reading flow and general UX). So, obviously, the simplest solution would be to just add target="_blank"
to all external links. This is okay, but obviously is a bit tedious, and also will require the developers always to remember to do that and pass this knowledge to future team members. What if we could automate it?
So, naturally, we need a directive that will
- Bind to all
a
elements - Determine if the link in its
href
attribute is external - Add
target="_blank"
to the element if it is - Possibly add a way to exclude some links from this behavior
- Also keep in mind that some links can change dynamically
So, let's first examine how we can determine if a link is external or not. It can be done by several fairly simple functions:
function getHostFromUrl (url: string) {
return new URL(url).hostname.replace("www.", "");
};
function isAbsoluteUrl (url: string) {
const formatedUrl = url.toLowerCase();
return formatedUrl.startsWith("http") || formatedUrl.startsWith("https");
};
function isUrlExternal (url: string, host = window.location.hostname) {
if (isAbsoluteUrl(url)) {
const providedHost = getHostFromUrl(url);
return providedHost !== host;
}
else {
return false;
}
};
We are leveraging the URL
constructor to parse the URL and check its origin.
Next, let's build a simple directive that can add target="_blank"
to external links. This directive will use signal inputs to keep track of both the current target attribute and the href
attribute:
type Target = '_blank' | '_self' | '_parent' | '_top' | '';
@Directive({
selector: 'a:not([noBlank])',
standalone: true,
host: {
'[target]': 'target()',
'[href]': 'href()',
},
})
export class ExternalLinkDirective {
targetRef = input<Target>('');
href = input.required<string>();
target = computed<Target>(
() => isUrlExternal(this.href()) ? '_blank' : this.targetRef()
);
}
Now, this directive is very simple (just 14 lines of code), and does everything we want from it; we take the href
, we figure out if the link is external or not, and we compute the target
attribute to put on the element. We also utilize a trick to be able to sometimes avoid this behavior.
The :not()
selector is a css selector supported by Angular Directive's API-s which allows the exclusion of some elements. Here we exclude all elements with noBlank
attribute. Now, we can just add noBlank
to the links we don't want to open in a new tab:
<a href="https://google.com">Google</a>
<a href="https://google.com" noBlank>Google</a>
Here is a full working example of this directive with a preview:
Use case 2: hijacking existing components
This part is completely inspired by a blog post written by Tim Deschryver, where he discusses how to use directives to extend functionality on components that we didn;t write. In that example, a directive is used to hijack the functionality of a Calendar
component from the PrimeNG UI library. Here is the example from this article:
import { Directive } from '@angular/core';
import { Calendar } from 'primeng/calendar';
@Directive({
selector: 'p-calendar',
})
export class CalenderDirective {
constructor(private calendar: Calendar) {
this.calendar.dateFormat = 'dd/mm/yy';
this.calendar.showIcon = true;
this.calendar.showButtonBar = true;
this.calendar.monthNavigator = true;
this.calendar.yearNavigator = true;
this.calendar.yearRange = '1900:2050';
this.calendar.firstDayOfWeek = 1;
}
}
Meaning we do not need to provide default inputs when working with the p-calendar
component, as they are already set by the directive. This is a great example of how to use directives to extend the functionality of existing components.
Give a read to Tim's article, it has lots of other very interesting use cases
The same approach can be used to modify existing components that we wrote in our application.
Going forward
As we have seen, directives are also powerful when dealing with existing functionality, and can be used to extend it, thus avoiding the need to wrap everything in components. In the next part, we are going to explore how directives can be used to work with events - both custom and native.
We have explored directive usage in template-local logic, structural directives instead of components, and using directives to extend the functionality of existing components and/or elements. This time around, we are going to find out how directives can be used to work with events and add events to components that do not really exist.
Let's get started with two interesting use cases!
A click-away directive
Sometimes we need to know when the user clicked outside of a given element. This is a common use case for dropdowns, modals, and other components that need to be closed when the user clicks outside of them. This can also be useful in a gaming app, or an app that shows videos (clicking away pauses the video, etc). We could do something inside of the component that has this functionality, but that would not be a very reusable piece of logic, considering we might need something like that in other components too. So, let's build a directive for this!
It is going to:
- take the target element
- inject the
Renderer2
instance to be able to listen to events - listen to all click events on the document element
- if the target is not a descendant of the clicked element (thus being outside of it), emit an event
- dispose of the event listener when the directive is destroyed
Here is our implementation:
@Directive({
selector: '[clickAway]',
standalone: true,
host: {
'(window:click)': 'handleKeyDown($event)',
}
})
export class ClickOutsideDirective {
private readonly elRef: ElementRef<HTMLElement> = inject(
ElementRef,
);
@Output() clickAway = new EventEmitter<void>();
handleKeyDown(event: KeyboardEvent) {
if (!this.elRef.nativeElement.contains(event.target as HTMLElement)) {
this.clickAway.emit();
}
}
}
As you can see, the logic is very straightforward. We inject the ElementRef
instance to get the target element, then use host
to be able to listen to window
events. We then listen to all click events on the window, and if the target is not a descendant of the clicked element (we check it using the Node.contains
method), we emit a clickAway
event. We then dispose of the event listener in ngOnDestroy
.
Now, the cool thing about naming the EventEmitter
the same as the directive selector is that we can just add this custom event on any element we like:
<div (clickAway)="onOutsideClick()">
<h1>Click outside of me!</h1>
</div>
Works like magic, as if it were a native event like (click)
or (mouseover)
!
Here is a working example with a preview:
Now, on to our next example.
Handling scrolling
Now, an often guest in different web apps is the ability to perform actions (like loading more content) when certain elements become visible in the view. This is a common use case for infinite scrolling, lazy loading, and other similar features. Again, as in the previous example, let us explore solutions that would allow us to reuse this logic in multiple places. Essentially, what we want is to have a custom event that would fire as soon as the element enters the viewport.
We are going to use an IntersectionObserver
for this, which allows observing whether a given element is intersecting with (essentially being visible inside) another element or the entire viewport. This will be a simplified example (lots of nuances can be present based on how exactly we want this to work), but the basic is that we can
- take a target element
- listen to all of its intersection changes
- If it is interesecting with the viewport, emit an event
Now, here is an implementation:
@Directive({
selector: '[scrollIntoView]',
standalone: true,
})
export class ScrollIntoViewDirective implements OnInit, OnDestroy {
@Input() threshold = 0.25;
@Output() scrollIntoView = new EventEmitter<void>();
elRef = inject(ElementRef);
observer: IntersectionObserver;
ngOnInit() {
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
this.scrollIntoView.emit();
}
});
},
{ threshold: this.threshold }
);
this.observer.observe(this.elRef.nativeElement);
}
ngOnDestroy() {
this.observer.disconnect();
}
}
Here, we create an IntersectionObserver
instance, and we listen to all of its changes. If the target element is intersecting with the viewport, we emit a scrollIntoView
event. We then disconnect the observer in ngOnDestroy
. As you can see, the implementation is fairly similar to what we did with the ClickAway
directive, the difference being the "business logic". Here is how we can use it in a template:
<div>
Really long content goes here
<div (scrollIntoView)="loadMoreContent()">
Dynamic content goes here
</div>
</div>
Again, works like magic as any other native event!
Here is the preview:
Note: the example shows a long list of
div
-s, scroll to the bottom to see a text message being logged into the console.
Next steps
As our exploration goes on, we learn more and more interesting use cases for Angular directives. In the next part, we will learn how to show templates outside of our components using directives and the concept of Portals.
In previous parts, we have already explored various applications of directives and dependency injection. This time around we shall see how we can use directives to help components communicate on the most hard-to-reuse level - the template.
So, let's get started with the next use case.
Dynamic shared templates
Imagine a fairly standard application UI structure: we have a header, a footer, maybe a sidebar, and some content in a main
tag. The header component is of high interest to us, because while it is the same for all pages, for certain pages it might require the addition of some custom templates. For example, if we are on the "Order Details" page, it may display the relevant product list, while on the "Shopping Cart" page it may display the cart summary. In other words, we need to be able to dynamically add some content to the header.
A relatively naive thing to do would be to subscribe in some way to the router and change the header template accordingly. But this has a couple of downsides:
- Header component will become bloated
- There won't be a clear way for components to communicate data to the header for their related pieces of the template
- We might need this sort of solution for other pages, meaning more bloat
What if we could just create the template in the component itself, and then somehow tell it to display that content in the header instead of its own template?
Turns out, this is entirely possible!
Let's see how
The Idea
For this example, we are going to use Angular Material, and specifically, its Portals feature. Portals come from the @angular/cdk
package and allow us to render a template outside of its original context. In our case, we will use them to render a template in the header component.
Note: this could be done without portals, or, anyway, without the
@angular/cdk
package, but this approach would simplify a couple of things. You are welcome to try this out with justng-template
-s
So, what is the general idea behind our solution? Three things
- An
ng-template
in the header in the correct place where want the dynamic content to be rendered, with the portal directive added to it - Our own custom directive that will capture a template from any other component
- A service that would communicate from the directive instance to any component (the header in our particular place) that wants to use the template
Let's start with the service, that actually shares the portal between consumers:
The Implementation
The Service
@Injectable({providedIn: 'root'})
export class PortalService {
private readonly portal$ = new Subject<
{portal: Portal<unknown> | null, name: string}
>();
sendPortal(name: string, portal: Portal<unknown> | null) {
this.portal$.next({portal, name});
}
getPortal(name: string) {
return this.portal$.pipe(
filter(portalRef => portalRef.name === name),
map(portalRef => portalRef.portal),
);
}
}
Let's understand what goes on here. First of all, we have the portal$
subject, which will take an object that describes the portal; it will receive a name (where we want to show the template, say, header
), and the portal itself. The sendPortal
method is used to send the portal to the service so that subscribers can use it, and the getPortal
method is used to get a particular portal from the service. The getPortal
method is quite simple, but it makes the service (and directive that will use it) very reusable so that we can send different templates to different places throughout the application.
So now, that we have the service, let's create the header component and use this service to display the content:
The Header Component
@Component({
selector: 'app-header',
standalone: true,
template: `
<mat-toolbar>
<span>Header</span>
<ng-template [cdkPortalOutlet]="portal$ | async"/>
</mat-toolbar>
`,
imports: [MatToolbarModule, PortalModule, AsyncPipe],
})
export class HeaderComponent {
private readonly portalService = inject(PortalService);
portal$ = this.portalService.getPortal('header');
}
As you can see, the component selects its specific portal template via our service, then uses the cdkPortalOutlet
directive to render it. We then use the async
pipe to subscribe to the portal observable and render the template when it is available. (note: if we pass null
to cdkPortalOutlet
, it will render nothing, that is going to be important in the directive).
As now we have ourselves on the receiving side of things, we can go on and create the directive that does the heavy lifting.
The Directive
As we are going to work with templates, the directive will be a structural one. We will call it portal
, and it will take an input with the same name, which will be the name of the portal we want to send the template to.
@Directive({
selector: "[portal]",
standalone: true,
})
export class PortalDirective implements AfterViewInit, OnDestroy {
private readonly templateRef = inject(TemplateRef);
private readonly vcRef = inject(ViewContainerRef);
private readonly portalService = inject(PortalService);
@Input() portal!: string;
ngAfterViewInit() {
const portalRef = new TemplatePortal(
this.templateRef,
this.vcRef,
);
this.portalService.sendPortal(this.portal, portalRef);
}
ngOnDestroy() {
this.portalService.sendPortal(this.portal, null);
}
}
As you can see, we inject both TemplateRef
and ViewContainerRef
to create a TemplatePortal
instance, which we then send to the service in the ngAfterViewInit
lifecycle hook. Actually, we do not do any manipulations on the portal, or the template, we delegate it all to the TemplatePortal
constructor. On ngOnDestroy
, we send null
to the service, so that the header component will remove the now obsolete template.
Now, we can try this in action:
The Usage
@Component({
selector: 'app-some-page',
standalone: true,
template: `
<main>
<span *portal="'header'">
Custom header content
</span>
<span>Some content</span>
</main>
`,
imports: [PortalDirective],
})
export class SomePageComponent {}
So in this example, the "Custom header content" text will not be rendered in this component, but rather, in the header component. Notice we did not import the HeaderComponent
, we did not put it in the template of the SomePageComponent
, or do anything else boilerplate-ish, we just dropped the portal
directive on some template, and that's it.
Another cool aspect of this is that the template that was "teleported" is still "owned" by the component in which it was written, meaning data bindings work as expected so that we can have dynamically changing data "portal-ed" somewhere else, like this:
@Component({
selector: 'app-some-page',
standalone: true,
template: `
<main>
<span *portal="'header'">{{someData}}</span>
<button (click)="changeContent()">
Change Content
</button>
</main>
`,
imports: [PortalDirective],
})
export class SomePageComponent {
someData = 'Custom header content';
changeContent() {
this.someData = 'New content';
}
}
Now, if we go on and click on the button, the header will change its content to "New content".
You can view this example in action here:
Click on the links to navigate from one page to another, and notice how the content in the header is changed dynamically
Final tasks
This time, we explored a more specific use case for an Angular directive. Directives, as mentioned multiple times throughout this series, are a very powerful tool, and one that is criminally underused. Next, we will cover the most controversial aspect of directives, which is how we can use them to bring business logic into our templates.
First of all, let's reflect on what we have already done, and understand how the next cases are fundamentally different.
In previous articles, we created directives that did some specific, reusable, and very useful things.
For example, we built a directive that
- checked for password strength
- emitted custom events when the host element scrolled into view
- transported a template view into another component
- displayed a loader
- added some default inputs onto existing components
As you can see, there is a common theme for all these cases - all those directives are super-reusable in the sense that they can be dropped in any project and just work; none of them really contain any business logic. But in a lot of cases, with real-life projects, it is the business logic that we want to be reusable and easily shareable. So what scenarios are there?
Using directives to handle permissions
Imagine that our app has permissions to enable or disable certain features. For example, we might have a feature that allows users to edit their profile, but only if they have the right permissions. We have a few PermissionService
that has a hasPermission
method that takes a permission name and returns a boolean. We could use this service in our component like this:
@Component({
selector: 'app-profile',
template: `
@if (hasPermission('edit-profile')) {
<div>
<button (click)="editProfile()">Edit profile</button>
</div>
}
`
})
export class ProfileComponent {
constructor(private permissionService: PermissionService) {}
hasPermission(permissionName: string) {
return this.permissionService.hasPermission(permissionName);
}
editProfile() {
// ...
}
}
But now every time we want to check for this or that permission, we need to inject the service, maybe write a wrapper method, call it from the template, or some other mildly cumbersome way. Also, if the way how we access those permissions changes (for example, the service now has a different API interface), we might face a pretty large refactoring. Also, if were have some sort of state management solution that works with Observables (like NgRx, for instance), our code will become even more complex.
So how do we deal with this?
Well, wouldn't it be nice if we could just do this:
@Component({
selector: 'app-profile',
template: `
<div *hasPermission="'edit-profile'">
<button (click)="editProfile()">Edit profile</button>
</div>
`
})
export class ProfileComponent {
editProfile() {
// ...
}
}
So now we can pass the permission name as an input to the directive, and the directive will handle the rest. Let's see how we can do this.
- First, we need to create a directive that injects the
PermissionService
and has an input property that will accept the permission name - next, we will add
ngIf
as a HostDirective so it can handle showing/hiding the template - and finally, we will inject the reference to the
NgIf
directive, and pass the resulting boolean to it
Let's do this:
@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,
);
}
}
Now, our directive works almost perfectly. What do we need to add? Of course, a possibility to handle the case when the user does not have the permission. We can do 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;
}
}
Now, we essentially just do the business logic and pass the result to the NgIf
directive. Here is a working example with a preview:
Using directives to handle shared data in components
Imagine we use some UI library that allows us to show dropdowns:
@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
}
];
}
And in some cases, we need to pass the same list of options to multiple dropdowns. For example, the same list of permissions we encountered earlier in this article could be really useful in lots of 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();
}
And then we could really see this same code in multiple places throughout our app. So how can we make this more reusable? Well, we could create a component that will wrap the dropdown and accept the list of items as an input:
<app-wrapper-around-third-party-dropdown
[items]="permissions"></app-wrapper-around-third-party-dropdown>
But that would introduce a set of problems:
- CSS encapsulation
- Having to pass eventually all of the third-party dropdown's inputs and outputs up and down
- More complexity in our templates
So what can we do instead? Well, we can create a directive that will inject 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();
}
}
And now we can use it like this:
<third-party-dropdown permissionList></third-party-dropdown>
And that's it! Now our template is simple, the third-party-dropdown
is still as it used to be, and we got additional reusable functionality for it.
Here is the example with a preview:
Last stop
We have explored a huge amount of possibilities that directives give us. While there are plenty more, this much is fairly enough for projects of different sizes, so this article series is nearing its end. In the next and last part, we will talk in detail about directive selectors, what we can use, how can we combine different selectors, and what are some pitfalls or where we cannot use a certain selector.
Directive Selectors
All Angular directives have a selector, which specifies which HTML elements will the directive work with. Most often (in my experience, around 95% of scenarios), the selector is an attribute selector, which means that the directive will essentially be a custom HTML attribute.
However, in previous articles, we saw that directives can be much more than just custom attributes. So, let us finish this series by exploring all of the other selector types, how they can be used, and some possible pitfalls, all with real-life, useful examples.
Non-custom attribute selectors
In this scenario, instead of inventing a new attribute, we use an existing, valid HTML attribute. This is useful when we want to extend the functionality of an existing HTML element, without having to create a new attribute for it.
For instance, we might put ids on our HTML elements to mark things that are needed for E2E testing or something like that. However, some E2E test runners might be using different attributes, like data-test-id
or data-qa-id
. Instead of copy-pasting the same id attribute with a different value, we can create a directive that will add the attribute we need based on the id
attribute:
@Directive({
selector: '[id]',
standalone: true,
host: {
'attr.data-qa-id': 'id',
'attr.data-test-id': 'id',
},
})
export class TestingIdDirective {
private readonly elRef = inject<ElementRef<HTMLElement>>(ElementRef);
get id() {
return this.elRef.nativeElement.id;
}
}
In this example, we just pick any element that has an id
property, and then set its value unto two new attributes data-qa-id
and data-test-id
using Host metadata. In the end, it does not matter what type of element we are working with, as long as it has an id
property, we can use this directive on it. You can find a working reproduction of this example here.
Of course, this example is very broad, and that is why we have to discuss our next, more narrow one.
Attribute selectors with values
As we saw, the previous example targeted elements that just had an id
attribute, without much regard as to what that particular id
is. However, there can be other motives for us to use an attribute selector. Let's consider the following example: we have many inputs of type number
, but in our application, we only want to allow positive numbers. If the user inputs a negative number, we want to outline the input with the color red. We have a CSS style that does exactly that when encountering a .invalid
class, so, what is left to us is to target inputs of type number
, check their value, and add or remove the .invalid
class based on that. We can do that with the following directive:
@Directive({
selector: 'input[type="number"]',
standalone: true,
host: {
'class.invalid': 'value',
'(input)': 'onInput()',
},
})
export class PositiveNumberDirective {
private readonly elRef = inject<ElementRef<HTMLInputElement>>(ElementRef);
value = false;
onInput() {
const value = this.elRef.nativeElement.value ?? 0;
this.value = (+value) < 0;
}
}
Again, we do not have to do much here other than importing the directive into the component we want to use it with, as it will automatically bind to the inputs of type number
and add the .invalid
class to them if the value is negative, which we will know because of the HostBinding
. You can find a working reproduction of this example here.
Next, let us discuss an often-overlooked use case of directive selectors.
Class selectors
Yes, you might have not heard about them, but directives can actually bind to CSS classes too! Consider the following scenario: in the previous example, we added the .invalid
class to the input, but now we want to add some logic to all the .invalid
elements in the application. For example, we might want to add a tooltip to them, which is also accomplished with a separate directive that already exists. We can achieve this with the power of Host Directives combined with a class selector:
@Directive({
selector: '.invalid',
standalone: true,
hostDirectives: [TooltipDirective],
})
export class InvalidTooltipDirective implements AfterViewInit {
private readonly tooltipRef = inject(TooltipDirective);
ngAfterViewInit() {
this.tooltipRef.title = 'The value is invalid';
}
}
This will kinda achieve what we want, however has a glaring problem, which we will discuss shortly after we cover all the selector types. At the moment, let's focus on our next level of knowledge about directive selectors - that is way more complex and versatile ones.
Complex selectors
So far, we discussed selectors that specifically target some HTML elements. This is handy, but there are scenarios where we might want to target more than one type of element, or maybe exclude some elements from an otherwise too broad selector. Let's see how we can achieve that.
Combining selectors
There are two ways of combining selectors to either broaden the "target audience", so to speak, of a directive: specifying a more concrete attribute or using multiple selectors. We already discussed the first scenario (the input[type="number"]
in the PositiveNumberDirective
two examples prior), so let us just talk about using multiple selectors.
Let's say we have a social media application built with Angular, where users can interact and talk to each other. Each user has an online status, which is determined by a number of things (just having a page open is not enough to consider a user to be "online" at the moment). One of those things is whether the user is typing something. There are a number of places where a user can type, for example, input
-s and textarea
-s. We want a directive that will capture that event and send a quick HTTP call to the server notifying that the user is currently online (there are a bunch of optimizations we can do about this, for instance, checking if the user is not already marked as "online", and making sure we do not send an HTTP call on each keystroke, but we will skip them for the sake of brevity, as those are out of the scope of this article). Let's build such a directive:
@Directive({
selector: 'input, textarea',
standalone: true,
host: {
'(input)': 'onInput()',
},
})
export class OnlineStatusDirective {
private readonly userService = inject(UserService);
onInput() {
this.userService.setOnlineStatus(true);
}
}
And again, as with other directives, just importing it into the component we want to use it with will be enough to make it work and target multiple types of elements. You can find a working reproduction of this example here. Let's now move on to our final entry, which helps narrow down the selector's specificity instead of broadening it.
Selector negation
Sometimes, we write a selector that targets all the elements that we want but encounters this one element that it just shouldn't apply to, despite matching. For instance, in the second example in this article, the PositiveNumberDirective
, targeted all the inputs of type number
, but we might have a specific input that we do not want to apply this directive to.
Our first thought might be to add an Input property to signify if the directive should actually work or not:
@Directive({
selector: 'input[type="number"]',
standalone: true,
host: {
'class.invalid': 'value',
},
})
export class PositiveNumberDirective {
@Input() enabled = true;
private readonly elRef = inject(ElementRef);
get value() {
const value = this.elRef.nativeElement.value ?? 0;
return this.enabled && (+value) < 0;
}
}
This works, but it has three downsides:
- We have to provide the boolean with a value each time we don't want the directive to work, which is not a big deal, but it is still a bit of a hassle.
- We might have to use the
enabled
property multiple times in the directive, as it possibly can have several host listeners, host bindings, etc. This makes the directive harder to read and understand. - Finally, the directive still gets applied, making Angular spend some unnecessary computational power on it.
Now, let's see how we can solve this problem with a selector negation, more commonly known as the :not()
selector. We can use it like this:
@Directive({
selector: 'input[type="number"]:not([allowNegative])',
standalone: true,
host: {
'class.invalid': 'value',
},
})
export class PositiveNumberDirective {
private readonly elRef = inject(ElementRef);
get value() {
const value = this.elRef.nativeElement.value ?? 0;
return (+value) < 0;
}
}
As we can see, the directive's code/logic remained the same, we just changed the selector, excluding all elements that have the allowNegative
attribute. Now, we can apply this "negative" attribute whenever we want to allow negative numbers, and the directive will not apply to that input.
<input type="number" allowNegative />
The :not()
selector specifier can accept any type of selector that is valid for a directive selector. You can find a working reproduction of this example here.
Finally, as we have covered all of the scenarios, it is time we briefly discuss some pitfalls we might encounter when working with directives.
The Pitfalls
With all the power of the selectors we saw in this article, it might be tempting to assume that it works exactly like a CSS selector. However, this could not be further from the truth. Let's see three main scenarios where this falls short.
Cannot target child-parent relations
While it certainly would be useful, it is impossible to target elements that are children of some specific parents only. We could try this:
@Directive({
selector: 'div > button',
standalone: true,
host: {
'(click)': 'onClick()',
},
})
export class ParentChildDirective {
onClick() {
console.log('clicked')
}
}
And then put this template:
<div>
<button>Directive is applied</button>
</div>
<button>Directive should not be applied</button>
When we click on the first button, we might rejoice as it will log "clicked". However, it will also log when we click on the second button, which is not inside a div
element. This is because Angular just picked the last element from the confusing (from its perspective) selector, and applied the directive to it. So, no complex parent-child (or sibling, ancestor, descendant, etc.) relations for directive selectors. Remember, Angular directive selectors are not exactly CSS selectors.
Directives cannot be applied dynamically
While it might certainly seem so, directives won't be "added" and "removed" as soon as an HTML element begins to match a certain selector. This becomes readily obvious with classes as directive selectors. When we built the InvalidTooltipDirective
, we mentioned that it has a glaring problem; the thing is, it might create the impression that whenever an element obtains the invalid
class, the directive will automatically apply to it; this is simply not the case: Angular directives get applied at compile-time, meaning this directive will be applied to elements that already have the invalid
class, but not to those that will obtain it later. You can see an example of how this works (or not works) here.
Structural directives can only be attributes
With structural directives, what happens is that Angular uses some clever syntactic sugar: an element marked with an asterisk automatically becomes wrapped in an <ng-template>
. So, if we just type, say, <div *class="some-class">Text</div>
, it will automatically translate to <ng-template [class]="some-class"><div>Text</div></ng-template>
. This is why structural directives can only be attributes, as they are applied to the <ng-template>
element, not the element that is inside it. We cannot, for instance, target the div
element in the previous example with a structural directive, as it is not the element that is marked with the asterisk. While this certainly restricts us from accomplishing some things, it is not very big of a deal, as we rarely need to do any logic in this regard.
Conclusion
I enjoyed writing this article series, and I hope it proved useful to readers as well. Angular directives are super powerful and underused, so I hope that this series will help everyone utilize them more often. My experience proved that doing this greatly simplifies codebases, especially the templates.
Small promotion
This article series took a long time to write (almost a year!). During that year, I was also busy writing a book. 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.