Original cover photo by Pawel Czerwinski on Unsplash.
Here we are, part 5 of our series dedicated to Angular's directives! In previous articles 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 today's 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
Conclusion
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. I hope this article will help you to understand how to use them, and how to create your own custom directives. Stay tuned for more use cases in the future!
Top comments (10)
Great article! Thank you for sharing.
I would like to know why you chose to pass a Portal instead of a TemplateRef directly.
Passing the TemplateRef would require few changes to your code and avoid the dependency on CDK.
First, in
header.component.html
Then, in
portal.directive.ts
And in
portal.service.ts
From my tests, it seems like this works in the same way as your solution using Portal.
I would like to know what you think about my modification. Do you think it would be a good alternative or do you have any other considerations?
This is defeinitely good and possible to do. I initally created the cdk/portals solution and so it in action, I thought maybe there are some other issues at play which the Portals solution address (that's why maybe they created it). Also at the very least cdk/portals allow us to send component instances and not just templates through it, so this example could be further expanded with Portals, not so much with
ng-template
-s. I alwys strive to make my articles "copy-pastable", that's why I just went with an existing solution, but anyway your example should definitely also do the thingThank you for your reply.
It's true, the power of Portal to handle components is very handy.
This is interesting! I'm not really familiar with Angular, but it's always cool to see how other frameworks (I come from a React background) divvy up the work! Two references I was confused about, in
PortalDirective
class definition, there are references toTemplateRef
andTemplatePortal
, but I don't see those defined anywhere. Where do they come from?TemplatePortal
is imported from@angular/cdk
library, whileTemplateRef
is a reference to the template. Angular de-sugars*portal="'header'"
into<ng-template [portal]="'header'">
and thanks to that we can injectTemplateRef
into the directive that queries[portal]
and Angular DI framework will provide us the template reference.Ok, but where does the
TemplateRef
reference itself come from? Is it also imported from the cdk library?It is imported from
@angular/core
.@shiftyp
TemplateRef
is the Angular's wrappert around a generic concept of a template (piece of HTML maybe with some local context). When we decorate something as aDirective
, when weinject
theTemplateRef
, we get access to the template of a particular instance of our directive, say, if we havex
directive, and we put it on a some element like this:<div [x]>...</div>
we will get reference to that whole template and can then work on it, remove it (like*ngIf
, transfer it somewhere else (like in this example) or add some characteristics to it (like in the 2nd article of the series). Hope this is helpful :)Love these series!
Thanks for your appreciation!