Connie Leung
Posted on February 15, 2024
Introduction
In this blog post, I would like to show a new feature in Angular 17.2 that is called signal queries. This new feature allows Angular engineers to use viewChild, viewChildren, contentChild, and contentChildren to obtain the value as a signal.
In my demos, I would like to apply these signal queries on a template-driven form and dynamic template respectively. Readers will find the three examples below:
- Apply viewChild on template-driven form
- Apply viewChildren to obtain a signal of button array. When I click a button, the background color changes and a template is shown in ngTemplateOutlet
- Repeat the second example, but use
contentChild
andcontentChildren
this time
Update the version of the angular dependencies
"@angular/animations": "^17.2.0-rc.1",
"@angular/common": "^17.2.0-rc.1",
"@angular/compiler": "^17.2.0-rc.1",
"@angular/core": "^17.2.0-rc.1",
"@angular/forms": "^17.2.0-rc.1",
"@angular/platform-browser": "^17.2.0-rc.1",
"@angular/router": "^17.2.0-rc.1",
In package.json
, update the version of the angular dependencies to 17.2.0-rc.1 to access the new signal queries feature.
Demo 1: use viewChild to access a template-driven form
type FormModel = {
name: string;
address: {
address1: string;
address2: string;
postalCode: string;
}
};
Define the mode of the template-driven form. The form has a name input and a form group that includes address1, address2, and postal code.
// app-simple-form.component.ts
<form #f="ngForm">
<div>
<label for="name">
<span>Name: </span>
<input id="name" name="name" type="text" required minlength="3" ngModel size="30">
</label>
</div>
<div ngModelGroup="address">
<div>
<label for="address1">
<span>Address 1: </span>
<input id="address1" name="address1" type="text" required minlength="3" ngModel size="50">
</label>
</div>
<div>
<label for="address2">
<span>Address 2: </span>
<input id="address2" name="address2" type="text" required minlength="3" ngModel size="50">
</label>
</div>
<div>
<label for="postalcode">
<span>Postal Code: </span>
<input id="postalCode" name="postalCode" type="text" required ngModel>
</label>
</div>
</div>
<button type="submit" [disabled]="!vm.isFormValid">Submit</button>
</form>
@if (vm.isSubmitted) {
<p>Data submitted: </p>
<pre>
{{ vm.formValues | json }}
</pre>
}
The template-driven form has a template reference f
that viewChild
uses to obtain a reference to NgForm. When a user clicks the submit button, the data is printed inside the <pre> block.
Manipulate form state with viewChild
// app-simple-form.component.ts
form = viewChild.required('f', { read: NgForm });
The demo uses the viewChild to query a NgForm with a template reference named f. The result is NgForm that is assigned to form member.
formValues = signal<FormModel>({
name: '',
address: {
address1: '',
address2: '',
postalCode: '',
}
});
isFormValid = signal(false);
isFormSubmitted = signal(false);
viewModel = computed(() => {
return {
formValues: this.formValues(),
isFormValid: this.isFormValid(),
isSubmitted: this.isFormSubmitted(),
}
});
get vm() {
return this.viewModel();
}
formValues
is a Signal that stores the form data when it changes. isFormValid
is a Signal that stores whether or not a form is valid. isFormSubmitted
is a boolean Signal that sets to true when a user clicks the submit button.
viewModel
is a computed signal that represents the view model of the component. Finally, I define a vm
getter to return the value of the viewModel signal.
This is my personal preference and you don’t have to follow it.
// main.ts
constructor() {
effect((onCleanup) => {
const formValueChanges$ = this.form().form.valueChanges.pipe(
debounceTime(0)
);
const sub = formValueChanges$.subscribe((v: FormModel) => {
this.formValues.set(v);
this.isFormValid.set(this.form().valid || false);
});
this.form().ngSubmit.subscribe(() =>
this.isFormSubmitted.set(true)
)
onCleanup(() => sub.unsubscribe());
});
}
}
When the form values change, effect
is run to update formValues
, isFormValid
, and isFormSubmitted
Signals respectively. Then, viewModel
is recomputed and it updates the vm
getter. The button is disabled when vm.isFormValid
is false. Similarly, when vm.isFormSubmitted
is true, the form values appear within the <pre> block.
Demo 2: use viewChild and viewChildren to select a dynamic template
// app-viewchild.component.ts
@Component({
selector: 'app-viewchild',
standalone: true,
imports: [NgClass, NgTemplateOutlet],
template: `
<div class="container">
<p>viewchild and viewchild demo</p>
<div>
<p>Select a template</p>
<button #el (click)="lastClickedBtn.set(1)" data-id="1"
[ngClass]="vm.btnClasses[0]">1</button>
<button #el (click)="lastClickedBtn.set(2)" data-id="2"
[ngClass]="vm.btnClasses[1]">2</button>
<button #el (click)="lastClickedBtn.set(3)" data-id="3"
[ngClass]="vm.btnClasses[2]">3</button>
<button #el (click)="lastClickedBtn.set(4)" data-id="4"
[ngClass]="vm.btnClasses[3]">4</button>
</div>
<ng-container *ngTemplateOutlet="vm.template; context: { $implicit: lastClickedBtn() }" />
</div>
<ng-template #t let-id>
<p>Simple Template {{ id }}</p>
</ng-template>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppViewChild {
lastClickedBtn = signal<number>(1);
btnGroups = viewChildren<ElementRef<HTMLButtonElement>>('el');
t = viewChild.required('t', { read: TemplateRef });
btnClasses = computed(() => {
return this.btnGroups().map((b) => {
const e = b.nativeElement;
const id = +(e.dataset['id'] || '1');
return id === this.lastClickedBtn() ? 'last-clicked' : '';
});
});
viewModel = computed(() => ({
template: this.t(),
lastClicked: this.lastClickedBtn(),
btnClasses: this.btnClasses(),
})
);
get vm() {
return this.viewModel();
}
}
I use viewChildren
to query a group of buttons with a template reference named el
. When a button is clicked, the lastClickedBtn
Signal stores the current button ID.
Then, the btnClasses
computed signal derives the CSS class of the button according to btnGroups
and lastClickedBtn
. When the button and the lastClickedBtn
signal have the same ID, change the background color of the button to blue. Otherwise, the button has the default button color.
<ng-template #t let-id>
<p>Simple Template {{ id }}</p>
</ng-template>
t = viewChild.required('t', { read: TemplateRef });
Then, I use viewChild
to query a TemplateRef
with a template reference named t
. The simple template displays “Simple Template” and the ID of the clicked button.
<ng-container *ngTemplateOutlet="vm.template; context: { $implicit: lastClickedBtn() }" />
The ngTemplateOutlet
directive renders the template and displays the value of lastClickedBtn Signal.
Demo 3: use contentChild and contentChildren to select a dynamic template
// app-contentchild.component.ts
@Component({
selector: 'app-wrapper',
standalone: true,
imports: [NgTemplateOutlet],
template: `
<div>
<ng-content select=".title" />
<ng-content select=".btn" />
</div>
<ng-container *ngTemplateOutlet="t; context: { $implicit: buttonId() }" />
<ng-template #t let-data>
<p>Simple Template {{ data }}</p>
</ng-template>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppWrapper implements AfterContentInit {
title = contentChild<ElementRef<HTMLParagraphElement>>('title');
btnGroups = contentChildren<ElementRef<HTMLButtonElement>>('btn');
buttonId = input.required<number>();
constructor() {
effect(() => {
this.setBackgroundColor(this.buttonId());
});
}
ngAfterContentInit(): void {
const title = this.title();
if (title) {
const { nativeElement: { style } } = title;
style.fontWeight = 'bold';
style.fontStyle = 'italic';
style.fontSize = '24px';
}
}
setBackgroundColor(buttonId: number) {
this.btnGroups().map((b) => {
const element = b.nativeElement;
const id = +(element.dataset['id'] || '1')
const className = buttonId === id ? 'last-clicked' : '';
element.className = className;
});
}
}
AppWrapper
is a component that consists of two ng-content
elements to display a title and a group of buttons. I used contentChild
to query the title
Signal and change its CSS styling in the ngAfterContentInit
method. I also used contentChildren
to query a group of buttons and derive their CSS class in the setBackgroundColor
method. When the buttonId
signal input and the button have the same ID, the button has a blue background color. Otherwise, the button has the default background color.
// app-contentchild.component.ts
@Component({
selector: 'app-contentchild',
standalone: true,
imports: [AppWrapper],
template: `
<div class="container">
<p>contentchild and contentchildren demo</p>
<app-wrapper [buttonId]="vm.clickedId">
<p class="title" #title>Select a template</p>
<button #btn class="btn" data-id="1" (click)="clickedBtn.set(1)">1</button>
<button #btn class="btn" data-id="2" (click)="clickedBtn.set(2)"
>2</button>
<button #btn class="btn" data-id="3" (click)="clickedBtn.set(3)">3</button>
<button #btn class="btn" data-id="4" (click)="clickedBtn.set(4)">4</button>
</app-wrapper>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppContentChild {
clickedBtn = signal(1);
viewModel = computed(() => ({
clickedId: this.clickedBtn(),
}));
get vm() {
return this.viewModel();
}
}
AppContentChild
is a parent component that projects the title and the buttons in the AppWrapper
component. When the button is clicked, it updates the clickedBtn
signal. The new signal value is passed to the input of the AppWrapper
component to trigger the re-rendering of the template and styling of the clicked button.
These are some of the examples of signal queries and I hope they are helpful to applications that regularly query components or HTML elements for styling and dynamic rendering.
The following Stackblitz repo shows the final results:
This is the end of the blog post that analyzes data retrieval patterns in Angular. I hope you like the content and continue to follow my learning experience in Angular, NestJS and other technologies.
Resources:
Posted on February 15, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.