Original cover photo by Pawel Czerwinski on Unsplash.
We have arrived at the fifth chapter in our examination of Angular directives. The earlier posts in this series have touched on a range of directive and dependency injection techniques. Our focus now shifts to a particularly challenging area for reuse: the template itself, and how directives can facilitate communication between components in that space.
Let's dive into the scenario for today.
Shared templates that update dynamically
Picture a common layout for an application: a header at the top, a footer at the bottom, perhaps a navigation sidebar, and a primary content area inside a main element. The header is particularly interesting. It's a single component used across the application, yet certain routes might benefit from displaying extra content within it. Take an "Order Details" page, for example, which could show the list of purchased items in the header. Or a "Shopping Cart" page, which might display a summary of the cart's contents. The core issue is that we need a way to inject custom templates into the header from other parts of the application.
A straightforward solution might involve subscribing to router events and modifying the header's template based on the current route. However, this approach has several drawbacks:
- The header component's logic would quickly become complex and unwieldy.
- There's no clean mechanism for a page component to pass data to its corresponding section in the header template.
- Scaling this pattern to other pages would only increase the complexity and bloat of the header component.
What if the component itself could define a template and then signal that the header should render this content in place of its own default? This would offer a clean separation of concerns.
It turns out that this is indeed achievable.
Let's explore how.
The Core Concept
For our demonstration, we'll leverage the Portals feature from Angular Material. Portals are part of the @angular/cdk package and provide a way to render a template in a location outside its original context. In this case, we'll employ a portal to render content within the header component.
Note: Achieving this outcome doesn't strictly require portals or the
@angular/cdkpackage. Usingng-templateelements alone is possible, but portals simplify the process. Feel free to experiment with an approach that only usesng-template-s.
So, what's the high-level plan? It involves three distinct parts:
- A dedicated
ng-templateplaced in the header, marking the exact location where the dynamic content should appear, with a portal directive attached. - A custom directive we create, which is used on a template in any other component to capture and share it.
- A service that acts as the communication channel, relaying the template from the directive instance to any component that wishes to consume it, like our header.
Let's begin by constructing the service, which is responsible for sharing the portal between the source and the destination.
Putting the Pieces Together
The Portal-Sharing 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 break down this code. We define a portal$ subject, which will hold an object containing a name, identifying the target location (e.g., header), and the portal itself. The sendPortal method provides a way to push a portal to the service for subscribers to access, while the getPortal method allows consumers to retrieve a portal by its name. The getPortal method's logic is straightforward, but it's this simplicity that makes the service and its associated directive highly reusable, enabling the dispatch of different templates to various destinations within the application.
With our service established, we can now create the header component that will subscribe to and display the dynamic content.
The Header Component and Portal Outlet
@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 shown, the component retrieves its specific portal template by name from the service. It then utilizes the cdkPortalOutlet directive to render it. The async pipe is used to subscribe to the portal observable, ensuring the template is rendered only once it is available. (A key point: passing null to cdkPortalOutlet results in nothing being rendered, which is important for our directive's cleanup logic).
Now that we have the receiving end ready, we can proceed to build the directive that will handle the template capture and transmission.
The Template-Capturing Directive
Since we're working with templates, our directive will be a structural one. We'll name it portal. It will accept an input, also named portal, which will be a string identifying the portal's destination name.
@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);
}
}
Here, we inject both TemplateRef and ViewContainerRef. These are used to create an instance of TemplatePortal, which is then sent to the service within the ngAfterViewInit lifecycle hook. It's important to note that we don't perform any operations on the portal or the template ourselves; all the logic is delegated to the TemplatePortal constructor. In the ngOnDestroy hook, we send null to the service. This action triggers the header component to clear the now-irrelevant template.
Let's see this in practice:
How to Use the Directive
@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 {}
In this example, the text "Custom header content" won't be displayed where it's written. Instead, it will be rendered within the header component. Observe that we did not import HeaderComponent, nor did we add it to the template of SomePageComponent in any way. There's no boilerplate code at all. We simply added the portal directive to a template, and that's all that was required.
A particularly powerful feature is that the "teleported" template still belongs to the component where it was written. This means that standard data bindings continue to function normally, allowing dynamic data to be "portal-ed" to a different part of the UI. For example:
@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';
}
}
When the button is clicked, the content within the header will update to display "New content".
You can see a live demonstration of this example at the link below, where navigating between pages showcases the header's content updating dynamically:
Final Thoughts
In this installment, we've tackled a very particular challenge that highlights the utility of directives beyond simple attribute manipulation. Throughout this series, we've seen that directives are an incredibly potent tool, and one that is often overlooked. It is my hope that this article sparks ideas on how to construct and apply your own custom directives to solve similar composition problems. We look forward to exploring even more use cases in the future. Stay tuned!
