Angular — Communicating between templates with function-like HTML segments

The function-like HTML segment refers to a block of HTML with the ability to accept context variables (in other words, parameters). A typical Angular component has two major parts of logic, a HTML template and a Typescript class. The capability to utilize this kind of function-like HTML segment is essential for a good shared component. It is because a shared component with only a fixed HTML template is very difficult to fit all the needs among all different use cases. Trying to satisfy all potential use cases with a single and fixed HTML template will usually end up with a large template with lots of conditional statements (like *ngIf), which is painful to read and maintain.

Here I would like to explain with an example, about how we can utilize TemplateRef to define function-like HTML segments for communication between templates, which is a good solution to deal with the large template problem.

Image from Pinterest

Getting started

Assume that there is a shared component DataListComponent, which takes an array of data and displays them in the view:

export interface DataTableRow {
dataType: string;
value: any;
}
@Component({
selector: 'data-list',
template: `
<div *ngFor="let row of data" [ngSwitch]="row.dataType">
<div *ngSwitchCase="'string'">{{row.value}}</div>
<div *ngSwitchCase="'number'"># {{row.value | number}}</div>
<div *ngSwitchCase="'date'">{{row.value | date}}</div>
</div>
`
})
export class DataListComponent {
@Input() data: DataTableRow[] = [];
}

It understands only three types of data now, which are string, number and date. When we want to add more types to it, the easiest way is to simply add more switch cases. It is totally fine when such new types are generic enough that have universal representations. Yet, for data that is depending on the users, adding more switch cases can make the code very dirty.

Say we want to add the new type boolean which displays true/false in FirstComponent, yes/no in SecondComponent. If we simply go for the more-switch-cases solution, it may have something like this:

<div *ngSwitchCase="'boolean-firstComponent'">
{{ row.value ? 'true' : 'false }}
</div>
<div *ngSwitchCase="'boolean-secondComponent'">
{{ row.value ? 'yes' : 'no}}
</div>

This approach is bad as the shared component now contains component-specific logic. Besides, this block of code is going to expand really fast when there are more new use cases in the future, which will soon become a disaster. Ideally, we want to pass HTML segments from the parents, so that we can keep those specific logic away from the shared component.

@Component({
template: `
<data-list [data]="data">
<!-- component specific logic to display true/false -->
</data-list>
`,
...
})
export class FirstComponent {...}
@Component({
template: `
<data-list [data]="data">
<!-- component specific logic to display yes/no -->
</data-list>
`,
...
})
export class SecondComponent {...}

Minimal working example

The logic behind is actually very straight forward. First, we need to define templates with context in the user components:

@Component({
template: `
<data-list [data]="data">
<ng-template let-value="value">
{{value ? 'true' : 'false'}}
</ng-template>
</data-list>
`,
...
})
export class FirstComponent {...}
@Component({
template: `
<data-list [data]="data">
<ng-template let-value="value">
{{value ? 'yes' : 'no'}}
</ng-template>
</data-list>
`,
...
})
export class SecondComponent {...}

Next, we add the logic to read and present the template segment inside the shared component:

@Component({
selector: 'data-list',
template: `
<div *ngFor="let row of data" [ngSwitch]="row.dataType">
<div *ngSwitchCase="'string'">{{row.value}}</div>
<div *ngSwitchCase="'number'"># {{row.value | number}}</div>
<div *ngSwitchCase="'date'">{{row.value | date}}</div>
<div *ngSwitchCase="'boolean'">
<ng-container *ngTemplateOutlet="rowTemplate; context:{
value: row.value
}"></ng-container>
</div>
</div>
`
})
export class DataListComponent {
@Input() data: DataTableRow[] = [];
@ContentChild(TemplateRef) rowTemplate: TemplateRef<any>;
}

Now we have a shared component which is capable to interpret a HTML segment from the outside. Yet, it is still not ideal. What if we have more than one templates?

Example with multiple templates

This one is more tricky. Although TemplateRef is capable of parsing context, it doesn’t have a name or ID that we can rely on to distinguish multiple templates from each other programmatically. As a result, we need to add a wrapper component on top of it when we have more than one templates, so that we can add identifiers.

@Component({
selector: 'custom-row-definition',
template: ''
})
export class CustomRowDefinitionComponent {
@Input() dataType: string;
@ContentChild(TemplateRef) rowTemplate: TemplateRef<any>;
}

Instead of directly retrieving the TemplateRef in the shared component, we retrieve the wrapper:

@Component({
selector: 'data-list',
template: `
<div *ngFor="let row of data" [ngSwitch]="row.dataType">
<div *ngSwitchCase="'string'">String: {{row.value}}</div>
<div *ngSwitchCase="'number'"># {{row.value | number}}</div>
<div *ngSwitchCase="'date'">{{row.value | date}}</div>
<ng-container *ngFor="let def of customRowDefinitions">
<ng-container *ngSwitchCase="def.dataType">
<ng-container
*ngTemplateOutlet="def.rowTemplate; context:{
value: row.value
}"></ng-container>
</ng-container>
</ng-container>
</div>
`
})
export class DataListComponent {
@Input() data: DataTableRow[] = [];
@ContentChildren(CustomRowDefinitionComponent)
customRowDefinitions: QueryList<CustomRowDefinitionComponent>;
}

(Having multiple ng-container together with structural directives may cause performance issue potentially, but it is not the main point of this article, so I leave it there for simplicity.)

In this example, we use the dataType property inside the wrapper as identifiers for the templates. As a result, we can now define multiple templates with different dataType.

@Component({
selector: 'app-root',
template: `
<data-list [data]="data">
<custom-row-definition dataType="array">
<ng-template let-value="value">
{{value.join(' - ')}}
</ng-template>
</custom-row-definition>
<custom-row-definition dataType="money">
<ng-template let-value="value">
$ {{value | number}}
</ng-template>
</custom-row-definition>
</data-list>
`
})
export class AppComponent {
data: DataTableRow[] = [
{ dataType: 'string', value: 'Row 1' },
{ dataType: 'number', value: 500 },
{ dataType: 'date', value: new Date() },
{ dataType: 'array', value: [1, 2, 3, 4] },
{ dataType: 'money', value: 200 }
]
}

Why not using ng-content?

Some may ask why don’t we just use ng-content with name to project the content from the outside? The major difference is the capability to have context (parameters). ng-content is like a function without parameters, which cannot achieve real mutual communication between templates. It is like a one-way channel to merge some HTML segments from the outside, but no real interaction with the template inside. It won’t be able to achieve use cases like the example above.

Thanks for reading! Hope you find this article helpful. Any comments would be highly appreciated. :D

Web developer from Hong Kong. Most interested in Angular and Vue. Currently working on a Nuxt.js + NestJS project.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store