DEV Community

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

Posted on

Superpowers with Directives and Dependency Injection: Part 2

Original cover photo by Markus Spiske on Unsplash.

In the previous post we took a look at how we can use dependency injection + directives to both simplify our templates and achieve reusability. In this one, we are going to explore structural directives and how we can make components and directives interoperate and reduce clutter in our .html files even further.

Let's build a loader component!

The Use Case

Imagine the following scenario: we have a component that loads some data, and we want to show a loading indicator while the data is being fetched. The following criteria should be met:

  1. We should be able to wrap any template inside our loader component, and it will display a spinner when needed
  2. The component should receive an input property indicating whether the data is being loaded or not
  3. The template should be covered by an overlay, so that the user cannot interact (and potentially trigger other HTTP calls) while the data is being loaded

Here is a pretty simple implementation:

@Component({
  selector: 'app-loader',
  template: `
    <div class="loading-container">
      <ng-content/>
      <div *ngIf="loading" class="blocker">
        <p-progressSpinner/>
      </div>
    </div>`,
  standalone: true,
  styles: [
    `
      .loading-container {
        position: relative;
      }
      .blocker {
        background-color: black;
        position: absolute;
        top: 0;
        z-index: 9999;
        width: 100%;
        height: 100%;
        opacity: 0.4;
      }
    `,
  ],
  imports: [NgIf, ProgressSpinnerModule],
})
export class LoaderComponent {
  @Input() loading = false;
}
Enter fullscreen mode Exit fullscreen mode

Note: I am using PrimeNG for the examples in this article, but you can easily reuse them with any other implementation

So, here we just project any content that we receive into ng-content and the rest is some simple CSS + PrimeNG ProgressSpinner component. The loading input property is used to toggle the spinner on and off.

Now we can use it in the template as follows:

<app-loader [loading]="loading">
  <p>Some content</p>
</app-loader>
Enter fullscreen mode Exit fullscreen mode

Wait, I thought this article is about directives?

Well, good news: it is about directives. But what's the problem with the component? Well, the example of its usage we saw was quite optimistic: real-life scenarios usually are not that simple. Consider this piece of template:

<app-loader [loading]="loading">
  <div class="p-grid">
    <div class="p-col-12">
      <p>Some content</p>
    </div>
    <app-loader [loading]="otherLoading">
        <div class="p-col-12">
            <p>Some other content</p>
            <app-loader [loading]="evenMoreLoading">
                <div class="p-col-12">
                    <p>Even more content</p>
                </div>
            </app-loader>
        </div>
    </app-loader>
  </div>
</app-loader>
Enter fullscreen mode Exit fullscreen mode

Now, here, when we have some nested elements, and the template keeps unnecessarily growing, adding more indentation levels, more closing tags, and so on, and so on. What I personally would really love to be able to do is the following:

<p *loading="loading">Some content</p>
Enter fullscreen mode Exit fullscreen mode

But how can we achieve this? Well, we need a directive that does the following things:

  1. Creates a LoaderComponent instance dynamically
  2. Somehow projects the nested template into it
  3. Keeps them in sync - when the loading input property changes, the directive should update the LoaderComponent instance accordingly
  4. Render the whole stuff

Let's dive into it!

Structural Directives

Structural directives are really cool, because they allow us to reference some templates via TemplateRef and do all sorts of magic with it.

Also, we can use the ViewContainerRef to create components dynamically. What will be left for us is to project the template into the component, And yes, this is possible! Let's start simple:

@Directive({
  selector: '[loading]',
  standalone: true,
})
export class LoaderDirective {
  private readonly templateRef = inject(TemplateRef);
  private readonly vcRef = inject(ViewContainerRef);
  @Input() loading = false;
  templateView: EmbeddedViewRef<any>;
  loaderRef: ComponentRef<LoaderComponent>;
}
Enter fullscreen mode Exit fullscreen mode

Here we injected the things we need (TemplateRef and ViewContainerRef), added a loading input, naturally, and created two properties: templateView and loaderRef. The first one will be used to store the reference to the template that we get, and the second one will be used to store the reference to the ComponentRef instance that we are going to create - we have to store both.

Next, left do some initial heavy lifting to set the whole thing up:

@Directive({
  selector: '[loading]',
  standalone: true,
})
export class LoaderDirective implements OnInit {
  private readonly templateRef = inject(TemplateRef);
  private readonly vcRef = inject(ViewContainerRef);
  @Input() loading = false;
  templateView: EmbeddedViewRef<any>;
  loaderRef: ComponentRef<LoaderComponent>;

  ngOnInit() {
    this.templateView = this.templateRef.createEmbeddedView({});
    this.loaderRef = this.vcRef.createComponent(LoaderComponent, {
      injector: this.vcRef.injector,
      projectableNodes: [this.templateView.rootNodes],
    });

    this.loaderRef.setInput('loading', this.loading);
  }
}
Enter fullscreen mode Exit fullscreen mode

Here, our ngOnInit lifecycle method does four things:

  1. Make the template into an embedded view, so we can render it dynamically
  2. Create a LoaderComponent instance
  3. Project the template into the LoaderComponent instance via projectableNodes - here is where the magic is happening!
  4. Set the loading input property on the LoaderComponent instance

Now, this way it will kinda work, but we need two more things to make it work properly:

  1. We need to update the LoaderComponent instance when the loading input property changes
  2. We need to ensure change detection still works on the projected template despite it being "detached" from the parent view and projected into a new component. We will use ngDoCheck for this

Let's finalize the implementation:

@Directive({
  selector: '[loading]',
  standalone: true,
})
export class LoaderDirective implements OnInit, DoCheck, OnChanges {
  private readonly templateRef = inject(TemplateRef);
  private readonly vcRef = inject(ViewContainerRef);
  @Input() loading = false;
  templateView: EmbeddedViewRef<any>;
  loaderRef: ComponentRef<LoaderComponent>;

  ngOnInit() {
    this.templateView = this.templateRef.createEmbeddedView({});
    this.loaderRef = this.vcRef.createComponent(LoaderComponent, {
      injector: this.vcRef.injector,
      projectableNodes: [this.templateView.rootNodes],
    });

    this.loaderRef.setInput('loading', this.loading);
  }

  ngOnChanges() {
    this.loaderRef?.setInput('loading', this.loading);
  }

  ngDoCheck() {
    this.templateView?.detectChanges();
  }
}
Enter fullscreen mode Exit fullscreen mode

Those additions are fairly simple: when the loading property of the component changes, we update the LoaderComponent instance accordingly, and when there is change detection running for the directive instance, we also notify the child template in the ngDoCheck lifecycle method via templateView.detectChanges(). If you are unfamiliar with how ngDoCheck works or why it is used, you can read the official docs, or this tutorial.

Now we can simply use it in the template, even when we have multiple nested elements:

<p *loading="loading">
    Some content
    <span *loading="otherLoading">
        Some other content
    </span>
    <p *loading="evenMoreLoading">
        Even more content
    </p>
</p>
Enter fullscreen mode Exit fullscreen mode

And so, there is no nested template, no unnecessary indentation, and no unnecessary closing tags. It's just a simple directive that does the job.

You can view the full example with a live demo on StackBlitz:

Conclusion

As mentioned previously, I believe directives are very, very powerful, but sadly underused in the wider community. With this series of articles I want us to explore different use cases where directives help us simplify our templates and improve readability. In the next piece, we will explore using directives to hack into existing components. Stay tuned!

Top comments (1)

Collapse
 
eugeneherasymchuk profile image
Yevhenii Herasymchuk

great idea for a series,
what about showing example, when users of directive would like to provide a custom loader component template?