Create Angular Reusable Components Angular

Apr 16th, 2021 - written by Kimserey with .

In previous post we looked at ViewChild which gave access to the elements within the view, and ContentChild which gave access to the elements within the decorated directive. Combining those two allows us to create reusable components flexible enough to allow users to define their contents. For example, creating a table component where we abstract away the way a table is constructed and the style applied to it, but leave fuill flexibility for the user of the table to define the content of each cell. In this post, we will look at how we can create a reusable card component which defines the look of a card but leaves the full control to the user to define its content.

Fixed Content

The most common way of using components in Angular is via Input and Ouput. For example we create a component which display a title and a body:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { Component, Input } from '@angular/core';

@Component({
  selector: 'app-card',
  template: `
    <div class="card">
        <div class="card-header">
            {{title}}
        </div>
        <div class="card-body">
            {{content}}
        </div>
    </div>
  `,
})
export class CardComponent {
  @Input() title: string;
  @Input() content: string;
}

which we can use with:

1
<app-card [title]="'my title'" [content]="'my content'"></app-card>

and the template will be rendered as such:

1
2
3
4
5
6
7
8
<div class="card">
    <div class="card-header">
        my title
    </div>
    <div class="card-body">
        my content
    </div>
</div>

With this way of creating components, we are able to abstract the template away a provide users a way to quickly reuse it by specifying the inputs. This type of component based on input is sufficient in almost all scenarios but in this particular example, the card component would benefit if the user was able to actually define the content of the card rather than fixing it to only string.

Fixed contents aren’t bad, in fact they cover most of the use cases where the component dictates the possibilities via the types of its inputs. It’s possible for the component to provide a large amount of possibilities to display content by using discriminated unions as input type which would provides many possibilities to the user and still being within boundaries.

But in some cases, it’s preferable to leave full control to the user which we will see how to do next.

Flexible Content

The key to providing full flexibility is to utilise ViewChild and ContentChild. By constructing an ingenious combination of both, we can provide full control to the user to define the content of the card while keep control on the template of the card itself.

We start first by looking at what we are trying to achieve. At the moment, we are aonly able to use the component this way:

1
<app-card [title]="'my title'" [content]="'my content'"></app-card>

what we want is to be able to define the content rather than be forced to use a string:

1
2
3
4
5
6
<app-card [title]="'my title'">
    <div>
        <h5>Hello World!</h5>
        <p>My content!</p>
    </div>
</app-card>

which will be rendered as such:

1
2
3
4
5
6
7
8
9
10
11
<div class="card">
    <div class="card-header">
        my title
    </div>
    <div class="card-body">
        <div>
            <h5>Hello World!</h5>
            <p>My content!</p>
        </div>
    </div>
</div>

The difference being that we are free to add any content we want, we have maximum flexibility. The first thing we’d need to do is to capture the template provided by the user within the <app-card></app-card>. In our previous post, we seen that the way to do this is to use ContentChild.

With ContentChild, we can access the content of the component:

1
@ContentChild('body') body: ElementRef;

But notice that in this case we’d only be able to retrieve ElementRef. This will not be enough, instead we’ll create a dedicated directive which will allow us to access the TemplateRef.

We go ahead and create appCardBody:

1
2
3
4
5
6
@Directive({
  selector: '[appCardBody]'
})
export class CardBody {
  constructor(public template: TemplateRef<any>) {}
}

As we see, we export via constructor the TemplateRef captured by the directive. With this new directive, we can then type the ContentChild:

1
@ContentChild(CardBody) body: CardBody;

Because we need to access the TemplateRef, we need to make sure to use it when using the component:

1
2
3
4
5
6
<app-card [title]="'my title'">
    <div *appCardBody>
        <h5>Hello World!</h5>
        <p>My content!</p>
    </div>
</app-card>

With this, we’ll capture the content of <app-card></app-card> in #CardBody.template.

Now that we have the body, we need to specify where to inject it into our component, this is entirely under our control rather than on the control of the user. To do so, we use ViewChild as it allows us to capture an element within our own template. Just like the concept of routerOutlet, we create a bodyOutlet which will be used to place the content capture by CardBody.

1
2
3
4
5
6
@Directive({
  selector: '[appCardBodyOutlet]'
})
export class CardBodyOutlet {
  constructor(public viewContainer: ViewContainerRef) {}
}

The outlet exposes its view container which we will use to inject the content of CardBody with createEmbeddedView. Then we can use ViewChild:

1
@ViewChild(CardBodyOutlet, { static: true }) bodyOutlet: CardBodyOutlet;

We specify static: true in order to have it resolved prior change detection since we know that it will never change. We now need to decide where to place the outlet so that we can inject the content later. For us it will be in the card body:

1
2
3
<div class="card-body">
    <ng-container appCardBodyOutlet></ng-container>
</div>

So far we have capture the body of the user defined content, and we know where we are going to place the content. The last part of the puzzle is to actually execute the placement, this is done in ngAfterContentInit:

1
2
3
4
5
6
7
8
9
export class CardComponent implements AfterContentInit {
  @Input() title: string;
  @ViewChild(CardBodyOutlet, { static: true }) bodyOutlet: CardBodyOutlet;
  @ContentChild(CardBody) body: CardBody;

  ngAfterContentInit() {
    this.bodyOutlet.viewContainer.createEmbeddedView(this.body.template);
  }
}

Very straight forward, after content init, we have access to the body.template and since bodyOutlet is static, it’s also avaible, hence embed the template of the body within the outlet!

Here’s the complete picture:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@Directive({
  selector: '[appCardBody]'
})
export class CardBody {
  constructor(public template: TemplateRef<any>) {}
}

@Directive({
  selector: '[appCardBodyOutlet]'
})
export class CardBodyOutlet {
  constructor(public viewContainer: ViewContainerRef) {}
}

@Component({
  selector: 'app-card',
  template: `
    <div class="card">
      <div class="card-header">
          {{title}}
      </div>
      <div class="card-body">
          <ng-container appCardBodyOutlet></ng-container>
      </div>
    </div>
  `,
})
export class CardComponent implements AfterContentInit {
  @Input() title: string;
  @ViewChild(CardBodyOutlet, { static: true }) bodyOutlet: CardBodyOutlet;
  @ContentChild(CardBody) body: CardBody;

  ngAfterContentInit() {
    this.bodyOutlet.viewContainer.createEmbeddedView(this.body.template);
  }
}

Which we use with:

1
2
3
4
5
6
7
8
<div class="container my-3">
  <app-card [title]="'my title'">
    <ng-container *appCardBody>
      <h5>Hello World!</h5>
      <p>My content!</p>
    </ng-container>
  </app-card>
</div>

And that concludes today’s post!

Conclusion

In today’s post we looked at how we could use a combination of ViewChild and ContentChild in order to create reusable components where the content could be user defined. We started by looking at a component with fixed content and then moved on to look at a flexible content component where we step by step looked at how we could enable the content flexibility. I hope you liked this post and I see you on the next one!

Designed, built and maintained by Kimserey Lam.