Superpowers with Directives and Dependency Injection: Part 5

Armen Vardanyan - Apr 3 '23 - - Dev Community

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:

  1. Header component will become bloated
  2. There won't be a clear way for components to communicate data to the header for their related pieces of the template
  3. 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 just ng-template-s

So, what is the general idea behind our solution? Three things

  1. 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
  2. Our own custom directive that will capture a template from any other component
  3. 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),
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

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');
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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';
    }
}
Enter fullscreen mode Exit fullscreen mode

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!

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .