Wrapping Angular Material button in custom Angular component (part 2)

In the first part of this series, we arrived at a solution that worked, but the code left a lot to be desired in terms of repetition. Let’s revisit that approach and then look at a more elegant alternative.

Here is the code we ended up with at the end of part 1:

<!-- my-button.component.html -->
<ng-container *ngIf="type === 'primary'">
  <button mat-flat-button color="primary">
    <ng-container [ngTemplateOutlet]="buttonContent"></ng-container>
  </button>
</ng-container>

<ng-container *ngIf="type === 'secondary'">
  <button mat-stroked-button color="primary">
    <ng-container [ngTemplateOutlet]="buttonContent"></ng-container>
  </button>
</ng-container>

<ng-container *ngIf="type === 'text'">
  <button mat-button color="primary">
    <ng-container [ngTemplateOutlet]="buttonContent"></ng-container>
  </button>
</ng-container>

<ng-template #buttonContent>
  <ng-content></ng-content>
</ng-template>

The redundancy here is hard to ignore. Is there a better way to structure this?

Solution 1

One straightforward idea is to extract each button type into its own dedicated component, moving the repetitive template logic into those new components. Consider something like this:

<!-- my-button.component.html -->
<primary-button *ngIf="type === 'primary'">
  <ng-container [ngTemplateOutlet]="buttonContent"></ng-container>
</primary-button>

<secondary-button *ngIf="type === 'secondary'">
  <ng-container [ngTemplateOutlet]="buttonContent"></ng-container>
</secondary-button>

<text-button *ngIf="type === 'text'">
  <ng-container [ngTemplateOutlet]="buttonContent"></ng-container>
</text-button>

<ng-template #buttonContent>
  <ng-content></ng-content>
</ng-template>

The <primary-button>* component could be implemented as follows:

import { Component } from "@angular/core";

@Component({
  selector: "primary-button",
  template: `<button mat-flat-button color="primary">
    <ng-content></ng-content>
  </button>`,
})
export class PrimaryButtonComponent {}

* Note: The <secondary-button> and <text-button> components are essentially identical, with the only difference being that the secondary button relies on the mat-stroked-button directive and the text button on mat-button.

StackBlitz demo.

This does improve things, but only marginally. Can we push this further?

Solution 2

Instead of using ngIf directives to determine which button to render, what if we relocated that decision logic into the component’s TypeScript class and leveraged an Angular API designed for dynamic component instantiation?

That API is ngComponentOutlet. The documentation describes this directive as offering a *declarative approach for dynamic component creation.* This means we specify which component should be created and where it should be inserted, while the Angular framework takes care of the rendering, updates, and teardown.

Let’s see this in action.

// my-button.component.ts
import { Component, Input } from "@angular/core";

import { PrimaryButtonComponent } from "../primary-button/primary-button.component";
import { SecondaryButtonComponent } from "../secondary-button/secondary-button.component";
import { TextButtonComponent } from "../text-button/text-button.component";

@Component({
  selector: "my-button",
  templateUrl: "./my-button.component.html",
})
export class MyButtonComponent {
  @Input() type: "primary" | "secondary" | "text" = "text";

  get buttonComponentType() {
    switch (this.type) {
      case "primary":
        return PrimaryButtonComponent;
      case "secondary":
        return SecondaryButtonComponent;
      case "text":
      default:
        return TextButtonComponent;
    }
  }
}
<!-- my-button.component.html -->
<ng-container
  *ngComponentOutlet="buttonComponentType; content: [[buttonContent]]"
></ng-container>

<div #buttonContent>
  <ng-content></ng-content>
</div>

The template has become significantly more concise. All the branching logic is gone. We’re passing two inputs to ngComponentOutlet: buttonComponentType, which is the class of the component to instantiate, and content, which carries a list of nodes intended for projection into the <ng-content> elements within the target component. Here, buttonContent is a template reference variable pointing to the div element.

* Note: The content property has a type of any[][], hence the nested square brackets around buttonContent. This also means multiple nodes can be projected. These nodes can be any element that implements the Node interface—such as a text node, a div, or a span.

Time for a demo 🎉 (StackBlitz)

Demo to solution 2: All buttons are still displayed correctly

From a visual standpoint, the buttons appear unchanged, but the underlying code is far more maintainable. The MyButton component is also more adaptable. Introducing a new button variant simply requires creating a new component and adding a case to the switch statement.

In this article, we’ve examined ngComponentOutlet and demonstrated how it can be used to dynamically instantiate components, meeting the core requirements laid out in part 1.

Of course, this is just the beginning. The MyButton component is unlikely to remain in its current form. As new use cases surface, it will need to adapt. In part 3, we will tackle additional features, such as icon support and the disabled state.

Thanks to Lars Gyrup Brink Nielsen for reviewing this post.

Photo by Chris Lawton on Unsplash