DEV Community

Cover image for Superpowers with Directives and Dependency Injection: Part 5
Armen Vardanyan for This is Angular

Posted on • Updated on

Superpowers with Directives and Dependency Injection: Part 5

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!

Top comments (10)

Collapse
 
wldomiciano profile image
Wellington Domiciano • Edited

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

@Component({
  selector: 'app-header',
  standalone: true,
  template: `
    <mat-toolbar>
      <span>Header</span>
      <ng-template [ngTemplateOutlet]="portal$ | async"/>
    </mat-toolbar>
  `,
  imports: [NgTemplateOutlet, MatToolbarModule, AsyncPipe],
})
export class HeaderComponent {
  private readonly portalService = inject(PortalService);
  portal$ = this.portalService.getPortal('header');
}
Enter fullscreen mode Exit fullscreen mode

Then, in portal.directive.ts

@Directive({
  selector: '[portal]',
  standalone: true,
})
export class PortalDirective implements AfterViewInit, OnDestroy {
  private readonly templateRef = inject(TemplateRef);
  private readonly portalService = inject(PortalService);
  @Input() portal!: string;

  ngAfterViewInit() {
    this.portalService.sendPortal(this.portal, this.templateRef);
  }

  ngOnDestroy() {
    this.portalService.sendPortal(this.portal, null);
  }
}
Enter fullscreen mode Exit fullscreen mode

And in portal.service.ts

@Injectable({ providedIn: 'root' })
export class PortalService {
  private readonly portal$ = new Subject<{
    portal: TemplateRef<unknown> | null;
    name: string;
  }>();

  sendPortal(name: string, portal: TemplateRef<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

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?

Collapse
 
armandotrue profile image
Armen Vardanyan

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 thing

Collapse
 
wldomiciano profile image
Wellington Domiciano

Thank you for your reply.

It's true, the power of Portal to handle components is very handy.

Collapse
 
shiftyp profile image
Ryan Kahn (he/him) • Edited

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 to TemplateRef and TemplatePortal, but I don't see those defined anywhere. Where do they come from?

Collapse
 
gitsobek profile image
Piotr Sobuś • Edited

TemplatePortal is imported from @angular/cdk library, while TemplateRef is a reference to the template. Angular de-sugars *portal="'header'" into <ng-template [portal]="'header'"> and thanks to that we can inject TemplateRef into the directive that queries [portal] and Angular DI framework will provide us the template reference.

Collapse
 
shiftyp profile image
Ryan Kahn (he/him)

Ok, but where does the TemplateRef reference itself come from? Is it also imported from the cdk library?

Thread Thread
 
gitsobek profile image
Piotr Sobuś

It is imported from @angular/core.

Thread Thread
 
armandotrue profile image
Armen Vardanyan

@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 a Directive, when we inject the TemplateRef, we get access to the template of a particular instance of our directive, say, if we have x 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 :)

Collapse
 
gitsobek profile image
Piotr Sobuś

Love these series!

Collapse
 
armandotrue profile image
Armen Vardanyan

Thanks for your appreciation!