Angular: @ViewChild() and @ContentChildren() decorators
With the release of OctoPerf's new UI we wanted to create a component that would allow our users to easily edit HTTP request actions.
The new UI being heavily inspired by IDEs such as Eclipse or Visual Studio we decided to create a component that behaves likes the project settings panel of IntelliJ:
This panel displays a tree on its left part with a search input on top. The content of the left part changes depending on the current selection.
While our simplified version will only display a list on the left panel, the idea is to create a composite component :
A visual component made of disparate or separate parts or elements, here a parent settings component and children settings panel components.
Composite Component¶
Concretely, from a development point of view, such component usage will be like this:
<lib-settings>
<lib-settings-panel>
<ng-container header>
Panel 1 header
</ng-container>
<div>Panel 1 content</div>
</lib-settings-panel>
<lib-settings-panel>
<ng-container header>
Panel 2 header
</ng-container>
<div>Panel 2 content</div>
</lib-settings-panel>
</lib-settings>
The parent component <lib-settings>
contains several <lib-settings-panel>
.
Each one of them defines:
- a header displayed in the left menu,
- a content displayed on the right when the header is selected.
How to make this happen? By using a combination of the @ViewChild
and @ContentChildren
parameter decorators.
Parameter decorators¶
Using @ViewChild in the panel component¶
The @ViewChild parameter decorator configures a view query, meaning that an element from the DOM can be injected into the component. The result of the query is dynamic and updated when the DOM changes.
Given the following HTML settings-panel.component.html
:
<ng-template #headerTemplate>
<ng-content select="[header]"></ng-content>
</ng-template>
<ng-template #contentTemplate>
<ng-content></ng-content>
</ng-template>
And the settings-panel.component.ts
:
import {Component, EventEmitter, Input, Output, TemplateRef, ViewChild} from '@angular/core';
@Component({
selector: 'lib-settings-panel',
templateUrl: './settings-panel.component.html',
styleUrls: ['./settings-panel.component.scss']
})
export class SettingsPanelComponent {
@ViewChild('headerTemplate', {static: true}) header!: TemplateRef<unknown>;
@ViewChild('contentTemplate', {static: true}) content!: TemplateRef<unknown>;
constructor(private service: SettingsService) {
}
public get isSelected(): boolean {
return this.service.isSelected(this);
}
}
The header
parameter points to the HTML <ng-template #headerTemplate>
element. Here we query the DOM
using the headerTemplate ID :
- Defined in the typescript with @ViewChild('headerTemplate'),
- Defined in the HTML with
.
The {static: true}
configuration of the @ViewChild decorator is a performance optimization: it tells Angular to
only
query the DOM once and not look for updates.
The TemplateRef refers to an embedded template (the <ng-template
part)
that
will later be used to instantiate embedded views in the parent Settings component.
The same applies for the content with the contentTemplate ID.
ng-content usage¶
If you are not familiar with the ng-content element it allows to inject HTML
in a component. In our example we create a <lib-settings-panel>
like this:
<lib-settings-panel>
<ng-container header>
Panel 1 header
</ng-container>
<div>Panel 1 content</div>
</lib-settings-panel>
The Panel 1 header text will be injected in the resulting HTML in place of the <ng-content select="[header]">
element and the <div>Panel 1 content</div>
in place of the <ng-content>
element.
Generated HTML:
<ng-template #headerTemplate>
Panel 1 header
</ng-template>
<ng-template #contentTemplate>
<div>Panel 1 content</div>
</ng-template>
The
<ng-content>
element can be used with a selector (for exampleselect="[header]"
) to only inject a specific part of the HTML (here<ng-container header>
) or without selector for the rest (here injecting the<div>Panel 1 content</div>
).
Using @ContentChildren in the settings component¶
Now that we have created a child settings-panel component with a header and a content, let's create a parent component that wraps it.
It will use the @ContentChildren parameter decorator.
The settings.component.ts
component is defined as follows :
import {
AfterContentInit,
Component,
ContentChildren,
EventEmitter,
HostBinding,
Input,
OnDestroy,
OnInit,
Output,
QueryList
} from '@angular/core';
import {SettingsService} from '@library/layout/settings/settings.service';
import {SettingsPanelComponent} from '@library/layout/settings/settings-panel/settings-panel.component';
@Component({
selector: 'lib-settings',
templateUrl: './settings.component.html',
styleUrls: ['./settings.component.scss'],
providers: [SettingsService]
})
export class SettingsComponent implements AfterContentInit, OnDestroy {
@ContentChildren(SettingsPanelComponent) panels!: QueryList<SettingsPanelComponent>;
private readonly subscriptions: Subscription[] = [];
constructor(public service: SettingsService) {
}
ngAfterContentInit(): void {
this._panelsChanged();
this.subscriptions.push(this.panels.changes.subscribe(this._panelsChanged.bind(this)));
}
ngOnDestroy(): void {
this.subscriptions.forEach(s => s.unsubscribe());
}
_panelsChanged(): void {
// React to panels Query list update
}
}
Children panel are injected using @ContentChildren(SettingsPanelComponent) panels!: QueryList<SettingsPanelComponent>;
While the ViewChild decorator allows to query for a single element from the view DOM, the ContentChildren decorator queries a list of elements in the content DOM.
Difference between view and content DOMs :
- The view DOM is the HTML directly defined in the component HTML file (for example in
settings-panel.component.html
for our previous panel component),- The content DOM is the HTML defined inside the component when it is used.
In our example the content DOM is the HTML that lies inside the SettingsComponent (
<lib-settings> Content DOM </lib-settings>
) element. This explains the many lib-settings-panel elements, allowing us to inject them in the parent component.
Here the selector (what's inside the parenthesis @ContentChildren(selector)) is the component
SettingsPanelComponent. It could be any class with the @Component
or @Directive
decorators, etc. Check
the API documentation for more information.
Also, we did not use the {static: true}
option here, meaning that if a child Settings panel is added or removed (for
example using an *ngIf
), the panels query list will be updated.
Content queries are set before the ngAfterContentInit
callback is called. So SettingsComponent implements
AfterContentInit and we subscribe to the panels QueryList changes
observable in the ngAfterContentInit method.
The _panelsChanged
method would typically react to changes in the panels list. For example if a panel is removed, the
currently selected one might need to be updated.
The HTML template for the settings.component.html
file makes usage of the component panel :
<div class="headers">
<div (click)="service.select(panel)" *ngFor="let panel of panels" class="header">
<ng-container [ngTemplateOutlet]="panel.header"></ng-container>
</div>
</div>
<div class="content">
<ng-template #emptyTemplate>
No match found!
</ng-template>
<ng-container [ngTemplateOutlet]="service.selected?.content || emptyTemplate"></ng-container>
</div>
The left part (<div class="headers">
) iterates over the list of panels and displays their headers.
The [ngTemplateOutlet]
directive points to
the SettingsPanelComponent.header
@ViewChild decorated parameter.
This directive injects the content of the template in its place.
The right part (<div class="content">
) displays the content of the currently selected SettingsPanel or the text 'No
match found!' if nothing is selected.
Settings Service¶
The SettingsService is used to keep track of the currently selected panel (settings.service.ts
) :
import {Injectable} from '@angular/core';
import {SelectionModel} from '@angular/cdk/collections';
import {SettingsPanelComponent} from '@library/layout/settings/settings-panel/settings-panel.component';
@Injectable()
export class SettingsService {
public readonly selection = new SelectionModel<SettingsPanelComponent>();
public get selected(): SettingsPanelComponent | undefined {
return this.selection.selected[0];
}
public select(panel: SettingsPanelComponent): void {
this.selection.select(panel);
}
}
It is provided in the SettingsComponent @Component({... providers: [SettingsService]}) export class SettingsComponent
.
So it is shared by both the SettingsComponent and its children SettingsPanelComponent. One instance of
SettingsService is created for each <lib-settings>
used in your application.
It's a simplified version of a SettingsService : it does not handle the search field or the disabled state of the panel. This goes beyond the scope of the blog post and will be left out.
Settings panel¶
The complete usage of this composite component looks like this :
It handles the selection, disabled and error states, a search that highlights text in the form fields, tables, code editors, and much more.
In case you want to see it in action and/or are interested in load testing web applications, feel free to create an account on OctoPerf's new UI.