This content originally appeared on DEV Community and was authored by Armen Vardanyan
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!
This content originally appeared on DEV Community and was authored by Armen Vardanyan
Armen Vardanyan | Sciencx (2023-04-03T09:50:18+00:00) Superpowers with Directives and Dependency Injection: Part 5. Retrieved from https://www.scien.cx/2023/04/03/superpowers-with-directives-and-dependency-injection-part-5/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.