The Loader Problem

Picture a typical scenario: a component needs to fetch data, and while that request is in flight, we want to show the user some kind of loading state. There are a few requirements worth keeping in mind:

  1. The loading wrapper should be able to encapsulate any template content, displaying a spinner whenever necessary
  2. A boolean input should control the loading state of the wrapper
  3. While loading, an overlay should prevent any interaction with the underlying content, avoiding accidental duplicate HTTP requests

Here is a straightforward way to build that component:

@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: For the examples here, I have chosen PrimeNG; however, you can adapt the same patterns to any UI library.

The component above uses ng-content for content projection, with the surrounding elements providing the overlay styling and the PrimeNG ProgressSpinner. The loading input simply toggles the spinner's visibility.

Using this component in a template looks like this:

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

Hold on, isn't this article about directives?

It is indeed. So what's the catch with the component approach? In real applications, templates aren't usually this neat. More often than not, you end up with something like this:

<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

With nested elements, the template grows in depth—more indentation, more closing tags—and it becomes harder to read. What would be much nicer is a syntax like this:

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

How can we make that work? The answer is a directive that takes on the following responsibilities:

  1. Creates a new LoaderComponent instance on the fly
  2. Transplants the template inside the directive into the component
  3. Synchronizes state—when the loading input changes, the directive updates the LoaderComponent instance
  4. Renders everything to the view

Let's walk through the implementation.

Structural Directives To The Rescue

Structural directives are a powerful feature because they let us grab a reference to a template through TemplateRef and manipulate it however we see fit.

On top of that, ViewContainerRef allows us to create components programmatically. The missing piece is projecting the directive's template into that dynamically created component—and yes, that's doable! Let's start with a basic outline:

@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 pull in the required dependencies—TemplateRef and ViewContainerRef—and define a loading input. We also set up two properties: templateView for the embedded view reference and loaderRef for the ComponentRef we'll produce. Storing both is essential.

Next, we take care of the initial setup:

@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

In the ngOnInit lifecycle hook, we complete four steps:

  1. Create an embedded view from the template, allowing it to be rendered dynamically
  2. Instantiate a LoaderComponent
  3. Pass the embedded view through projectableNodes to project it into the LoaderComponent—this is the key move
  4. Assign the initial value of the loading input to the component instance

At this point, things mostly work, but two adjustments are necessary for a fully functional solution:

  1. When the loading input changes, the directive must push that change to the LoaderComponent
  2. Since the projected template is detached from its original view, we need to keep change detection alive for it. The ngDoCheck hook is the right place for that

Here's the complete directive:

@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

The extra logic is straightforward: a setter on the loading input propagates new values to the loader component, and the ngDoCheck lifecycle method calls templateView.detectChanges() to keep the projected content in sync with the parent view's change detection cycle. If you want more background on ngDoCheck, the official docs or this tutorial should help.

Armed with this directive, the template becomes much cleaner, even when it holds several 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

No wrapper elements, no extra indentation, no closing tags piling up. Just a simple directive doing its job.

You can check out a live demo on StackBlitz:

Wrapping Up

Directives are incredibly capable, yet they don't get the attention they deserve in the Angular community. Through this series, I want to highlight the various ways directives can make our templates more concise and our code more readable. In the next installment, we'll look at how directives can be used to hook into existing components. See you there!