Objective of this article is to depict in a show-case how to use Angular Forms record type FormRecord in a short example.
At the the time of writing this article, the documentation regarding FromRecord
has some history on angular.dev
website,
but it may be helpful to get a practical example.
Show-case requirements
The example we are going to build is a simple Angular Control-Value-Accessor (CVA) component allowing to select values form a number of options
using a list of checkboxes. This component is used to select a number of toppings
for a pizza. The component will have a form with a checkbox for each topping. The form will be implemented using
FormRecord
, here as FormRecord<FormControl<boolean|null>>
.
An additional requirement is, that we want to sell two different kinds of pizza:
- Traditional Pizza
- Vegetarian Pizza
The user shall be able to select the toppings for each kind of pizza separately.
For traditional pizza, the available toppings shall be:
- Mozzarella
- Onions
- Mushrooms
- Pepperoni
- Sausage
- Bacon
- Cooked Ham
- Pineapple
For vegetarian pizza, the available toppings shall be:
- Mozzarella
- Onions
- Herbs
- Broccoli
- Bell Pepper
- Olives
- Pepperoni
- Pineapple
It may not be your favorite pizza, but it is good enough to demonstrate the uses of a FormRecord
.
To finalize the show-case requirements:
- The shall be a PizzaComponent, which provides the form controls for the kind of pizza and the toppings.
- Selection of pizza kind shall be implemented using radio buttons.
- The user shall be able to select the toppings for each kind of pizza separately.
- The selection of toppings shall be implemented using a list of checkboxes. In a CVA component receiving currently selected and available toppings as input:
- Available toppings shall be displayed as a list of checkboxes and come via required input
options
as an array of strings. The options shall be displayed as checkboxes. They may change over time, so the component shall be able to handle changes of the available toppings. - Currently selected toppings shall be displayed as checked and come via CVAs
writeValue
method as an array of strings. - Actually selected toppings shall be emitted via CVAs
onChanged
as an array of strings. It shall include only values, that are valid options. - Standard form behavior shall be respected:
- The form shall remain
pristine
, until the user has actually interacted with it. - The form shall be
touched
, when the user has interacted with it: by selecting a value, by deselecting a value or by blur.
- The form shall remain
- Available toppings shall be displayed as a list of checkboxes and come via required input
Implementation
The Umbrella component PizzaComponent
will provide the form controls for the kind of pizza and the toppings. It's typed form group is:
/**
* Kinds of Pizza
*/
export type PizzaKindType = 'TRADITIONAL' | 'VEGETARIAN';
/**
* Interface for the pizza form.
*/
export interface PizzaForm {
/**
* Kind of the pizza.
*/
kind: FormControl<PizzaKindType>;
/**
* Toppings of the pizza.
*/
toppings: FormControl<string[]>;
};
The PizzaComponent
will provide the form controls for the kind of pizza and the toppings. The component shall be standalone,
shall use Java-Script private properties, explicit dependency injection via "inject",
Angular's typed reactive forms and shall use change detection policy "on-push".
As a special feature, the component shall include a signal describing the form value, the form status, the form pristine state, the form touched state and the form control errors. Signal properties shall be updated on event emission for the form events, form value, and the form status.
The toppings shall be displayed as checkboxes. The CheckboxListComponent
shall be a simple CVA component. It shall receive the available toppings as an input and shall emit the currently selected toppings. The topping cheese shall be pre-selected.
The PizzaComponent
's Typescript code is straight-forward:
import { JsonPipe } from '@angular/common';
import { ChangeDetectionStrategy, Component, inject, OnDestroy, OnInit, signal, WritableSignal } from '@angular/core';
import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { merge, Subject, takeUntil, tap } from 'rxjs';
import { CheckboxListComponent } from '../checkbox-list/checkbox-list.component';
import { PizzaForm, PizzaKindType } from './pizza.model';
@Component({
selector: 'app-pizza',
imports: [
JsonPipe,
ReactiveFormsModule,
CheckboxListComponent
],
templateUrl: './pizza.component.html',
styleUrl: './pizza.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PizzaComponent implements OnInit, OnDestroy {
/**
* DI
*/
#fb = inject(FormBuilder);
/**
* Available pizza toppings for different kinds of pizza
*/
toppingsAvailable: Record<PizzaKindType, string[]> = {
TRADITIONAL: ['Mozzarella', 'Onions', 'Mushrooms', 'Pepperoni', 'Sausage', 'Bacon', 'Cooked Ham', 'Pineapple'],
VEGETARIAN: ['Mozzarella', 'Onions', 'Herbs', 'Broccoli', 'Bell Pepper', 'Olives', 'Pepperoni', 'Pineapple']
};
/**
* Form to order a pizza
*/
form = this.#createForm();
/** Destroy subject */
#destroy$ = new Subject<void>();
/**
* Signal providing form debug information
*/
formDebug: WritableSignal<{
formValue: any;
formStatus: string;
formErrors: any;
pristine: boolean;
touched: boolean;
toppingsErrors: any;
}> = signal({
formValue: this.form.value,
formStatus: this.form.status,
formErrors: this.form.errors,
pristine: this.form.pristine,
touched: this.form.touched,
toppingsErrors: this.form.controls.toppings.errors
});
/**
* Implements Angular's OnInit interface
*/
ngOnInit(): void {
merge(this.form.events, this.form.valueChanges, this.form.statusChanges).pipe(
takeUntil(this.#destroy$),
tap(() => {
this.formDebug.set({
formValue: this.form.value,
formStatus: this.form.status,
formErrors: this.form.errors,
pristine: this.form.pristine,
touched: this.form.touched,
toppingsErrors: this.form.controls.toppings.errors
});
})
).subscribe();
}
/**
* Implements Angular's OnDestroy interface
*/
ngOnDestroy(): void {
this.#destroy$.next();
this.#destroy$.complete();
}
/**
* Create the form
*/
#createForm(): FormGroup<PizzaForm> {
return this.#fb.group({
kind: this.#fb.control<PizzaKindType>('TRADITIONAL'),
toppings: this.#fb.control<string[]>(['Mozzarella'])
});
}
}
You may wonder on some code details:
- Since we are using Angular-V19, the "standalone" property is not set. When using older Angular versions, add
standalone: true
. - We are using
ChangeDetectionStrategy.OnPush
to optimize the change detection. This is a good practice, but not strictly required. It will be helpful in larger applications to reduce the number of change detection cycles. And at the end, it will enable us to run Angular zone-less. - We are using Java-Script private properties, which are not yet common in Angular. So class properties with
#
-prefix are really private. - We are using explicit DI in Angular using the function
inject
. With older versions of Angular, you may use the components constructor for DI.
It seems that the modern code is less verbose, shorter, clearer and more expressive.
The HTML template of the PizzaComponent
is also straight-forward:
<h1>Create your Pizza!</h1>
@let fd = formDebug();
<form [formGroup]="form" aria-label="Pizza selection">
<section>
<fieldset>
<legend>Kind of Pizza:</legend>
<div>
<input type="radio" id="TRADITIONAL" name="kind" value="TRADITIONAL" formControlName="kind" />
<label for="TRADITIONAL">Traditional</label>
</div>
<div>
<input type="radio" id="VEGETARIAN" name="kind" value="VEGETARIAN" formControlName="kind" />
<label for="VEGETARIAN">Vegetarian</label>
</div>
</fieldset>
</section>
<section>
<fieldset>
<legend>Choose your pizza toppings:</legend>
<app-checkbox-list formControlName="toppings"
[options]="form.controls.kind.value ? toppingsAvailable[form.controls.kind.value] : []" required />
<div class="error">
@if(form.controls.toppings.errors?.['required']) {
At least one topping must be selected
}
</div>
</fieldset>
</section>
<hr>
<p>Form value: {{ fd.formValue | json }}</p>
<p>Form status: {{ fd.formStatus | json }}</p>
<p>Form pristine: {{fd.pristine | json}}</p>
<p>Form touched: {{fd.touched | json}}</p>
<p>Form controls.toppings.errors: {{fd.toppingsErrors| json}}</p>
</form>
- The selection of the pizza kind is implemented using radio buttons.
- The template uses the
CheckboxListComponent
to display the available toppings as checkboxes. - In the footer of the form, we display the form value, the form status, the form pristine state, the form touched state and the form control errors.
- The options binding of the
CheckboxListComponent
. It is bound to the available toppings for the currently selected pizza kind.
What is missing: a button to order the Pizza.
Deep dive into the CheckboxListComponent
The CheckboxListComponent
is a simple Control-Value-Accessor (CVA) Angular component.
This means:
- It implements the
ControlValueAccessor
-Interface. - Since it is a basic component, it does not implement the
Validator
-Interface. - Currently selected values are passed to the component via the
writeValue
method. - The component emits the currently selected values via the method registered by
registerOnChange
. In our case it is theonChanged
method. - Similar to the
registerOnChange
method, the component also registers a method for theregisterOnTouched
method. This method is called when the user has interacted with the form control. The associated method isonTouched
and emits ontouched
-events and on user initiated value changes.
The template of the PizzaComponent
uses the CheckboxListComponent
to display the available toppings as checkboxes.
It is used like this in PizzaComponent
:
<app-checkbox-list formControlName="toppings"
[options]="form.controls.kind.value ? toppingsAvailable[form.controls.kind.value] : []" required />
<div class="error">
@if(form.controls.toppings.errors?.['required']) {
At least one topping must be selected
}
</div>
With PizzaComponent
providing the form control for the toppings and available toppings.
The form control toppings
receives a list of currently selected toppings. Type is FormControl<string[] | null>
.
So the CheckboxListComponent
does not know anything about e.g. pre-selected values. And it also
does not know anything about the available options. This is all provided by the PizzaComponent
.
As already mentioned, CheckboxListComponent
shall just implement (ControlValueAccessor
), but not the Validator interface.
Instead it shall use the required
attribute to indicate that at least one topping must be selected. The attribute required
will add the Angular RequiredValidator
-directive to the 'toppings' form control of the PizzaComponent
.
This works, because the CheckboxListComponent
will report null
, if no checkbox has been selected.
The CheckboxListComponent
is implemented as follows:
import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, forwardRef, inject, Input, OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core';
import { ControlValueAccessor, FormBuilder, FormControl, FormGroup, FormRecord, NG_VALUE_ACCESSOR, ReactiveFormsModule, TouchedChangeEvent } from '@angular/forms';
import { map, Subject, takeUntil, tap } from 'rxjs';
/**
* Interface for the form
* - checkboxes: List of checkboxes showing options
*/
export interface CheckboxListForm {
/**
* List of checkboxes
*/
checkboxes: FormRecord<FormControl<boolean | null>>;
};
@Component({
selector: 'app-checkbox-list',
imports: [
ReactiveFormsModule,
],
templateUrl: './checkbox-list.component.html',
styleUrl: './checkbox-list.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => CheckboxListComponent),
multi: true
}
]
})
export class CheckboxListComponent implements OnInit, AfterViewInit, OnChanges, OnDestroy, ControlValueAccessor {
/**
* DI
*/
#changeDetectorRef = inject(ChangeDetectorRef);
#fb = inject(FormBuilder);
/**
* The form
* - creates a form group for checkboxes in the form
*/
form: FormGroup<CheckboxListForm> = this.#fb.group<CheckboxListForm>({
checkboxes: this.#fb.record<FormControl<boolean | null>>({}),
});
/**
* Required input providing options to be rendered as checkboxes
* - label values for the checkboxes
*/
@Input({ required: true }) options: string[] | null = null;
/** Destroy subject */
#destroy$ = new Subject<void>();
/**
* Part of the control value accessor interface (onTouched) implementation.
*/
onTouched: () => void = () => { };
/**
* Part of the control value accessor interface (onChanged) implementation.
*/
onChanged: (value: string[] | null) => void = () => { };
/**
* Implements part of control value accessor interface (writeValue)
* This method is called in case the upper layer components would like to update
* this component.
*
* @param value - the value written to the component
*/
writeValue(value?: string[] | null): void {
this.#syncCheckboxControls(value ?? []);
}
/**
* Implements part of control value accessor interface (registerOnChange)
*/
registerOnChange(fn: (value: string[] | null) => void): void {
this.onChanged = fn;
}
/**
* Implements part of the control value accessor interface (registerOnTouched)
*/
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
/**
* Implements part of the control value accessor interface (setDisabledState)
*/
setDisabledState(isDisabled: boolean): void {
if (isDisabled === true) {
// this disables all form controls
this.form.disable();
} else {
this.form.enable();
}
}
/**
* Implements Angular's OnInit interface
*/
ngOnInit(): void {
/**
* Monitor form events
* - react on TouchedChangeEvent {touched: true, source: <any>}
*/
this.form.events.pipe(
takeUntil(this.#destroy$),
tap((formEvent) => {
if (formEvent instanceof TouchedChangeEvent && formEvent.touched) {
this.onTouched();
}
})
).subscribe();
}
/**
* Implements Angular's AfterViewInit interface
*/
ngAfterViewInit(): void {
/**
* Monitor form value changes and emit value changes
* via the control value accessor interface
*/
this.form.valueChanges.pipe(
takeUntil(this.#destroy$),
map(() => this.form.getRawValue()),
map((formValue) => Object.keys(formValue.checkboxes).filter(key => formValue.checkboxes[key])),
tap((selectedValues) => {
this.onChanged(selectedValues.length > 0 ? selectedValues : null);
/** mark as touched */
this.onTouched();
})
).subscribe();
}
/**
* Implements Angular's OnChanges interface
*/
ngOnChanges(changes: SimpleChanges): void {
if (changes['options']) {
const selectedValues = Object.keys(this.form.getRawValue().checkboxes).filter(key => this.form.getRawValue().checkboxes[key]);
this.#syncCheckboxControls(selectedValues);
}
}
/**
* Implements Angular's OnDestroy interface
*/
ngOnDestroy(): void {
this.#destroy$.next();
this.#destroy$.complete();
}
/**
* Getter to access form record keys
*/
get checkboxControlNames(): string[] {
return Object.keys(this.form.controls.checkboxes.controls);
}
/**
* Synchronize checkbox controls based on options and value
* @param value is the applicable value (selected options)
*/
#syncCheckboxControls(value: string[]): void {
const options = this.options ?? [];
const nextControls = options.reduce((acc, option) => ({ ...acc, [option]: this.#fb.control<boolean | null>(value.includes(option)) }), {});
this.form.setControl('checkboxes', this.#fb.record<FormControl<boolean | null>>(nextControls));
this.#changeDetectorRef.markForCheck();
}
}
The first life-cycle hook is ngOnInit
. It is used to monitor form events. The component reacts on TouchedChangeEvent
events
and calls the onTouched
method. This is done to mark the form as touched, when the user has interacted with the form control.
Then the parent component calls the writeValue
method of the CheckboxListComponent
to pass the currently selected values.
This will trigger the #syncCheckboxControls
method to synchronize the form controls with the written value and options. It actually
replaces all current checkbox controls with new controls based on options and according to current values.
Finally, the method calls this.#changeDetectorRef.markForCheck()
to trigger change detection.
After the form has been initialized, the component monitors the form value changes in ngAfterViewInit
. The component emits the currently selected values via the onChanged
method. By establishing the monitoring of value changes of CheckboxListComponent
in ngAfterViewInit
, the complete form remains pristine until the user actually interacted with the form.
The visible part of the CheckboxListComponent
is provided vy the HTML template:
<form [formGroup]="form">
<div formGroupName="checkboxes">
@for(controlName of checkboxControlNames; track controlName) {
<div>
<input type="checkbox" [id]="controlName" [name]="controlName" [formControlName]="controlName" />
<label [for]="controlName">{{controlName}}</label>
</div>
}
</div>
</form>
Now the state is, that everything is set up and the form is ready to be used and the "cheese" topping is pre-selected.^ The kind of pizza is traditional and the user can select additional toppings - such as onions, mushrooms, pepperoni, sausage, bacon, cooked ham, and pineapple.
But at some point in time the user may decide to switch to vegetarian pizza. The user selects the vegetarian pizza kind and the available toppings change. The user can now select additional toppings - such as herbs, broccoli, bell pepper, olives and other toppings can not be selected anymore.
This is achieved in PizzaComponent by binding the options
input of the CheckboxListComponent
to the pizza kind VEGETARIAN
. The CheckboxListComponent
's input options
changes. This triggers the ngOnChanges
life-cycle hook of the CheckboxListComponent
. The #syncCheckboxControls
method is called to synchronize the form controls with the new options and the current values.
Outcome is, that the user can now select additional toppings - such as herbs, broccoli, bell pepper, olives. And other toppings can not be selected anymore.
So far, we have implemented a simple Angular form using FormRecord
and a Control-Value-Accessor (CVA) component. The form allows the user to select toppings for a pizza. The user can select the kind of pizza and the toppings for each kind of pizza separately. The form is implemented using FormRecord<FormControl<boolean|null>>
and the CheckboxListComponent
is a simple CVA component.
Optimization
All the pizza related components use Angular's change detection strategy OnPush
. So we want to make the complete application using
change detection strategy OnPush
. At ADVENAGE, the demo app had been created using the Angular CLI v18. An the base configuration
of the AppComponent
was set to ChangeDetectionStrategy.OnPush
. This has been added:
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { PizzaComponent } from './pizza/pizza.component';
@Component({
selector: 'app-root',
imports: [
PizzaComponent
],
templateUrl: './app.component.html',
styleUrl: './app.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AppComponent {
title = 'adv-sample-ng-forms-form-record';
}
The related HTML template of the AppComponent
is:
<app-pizza/>
Given that, the application can be built for production:
ng build
With result:
Initial chunk files | Names | Raw size | Estimated transfer size
main-ZEWA7YZH.js | main | 244.77 kB | 63.98 kB
polyfills-FFHMD2TL.js | polyfills | 34.52 kB | 11.28 kB
styles-Q6XDLNNY.css | styles | 44 bytes | 44 bytes
| Initial total | 279.33 kB | 75.31 kB
Application bundle generation complete. [1.486 seconds]
So, the estimated transfer size is 75.31 kB. This is a good result for a small application. But we can do better.
The application template included routing
First step is to remove routing from app.config.ts
:
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
]
};
This already reduces the estimated transfer size to 56.76 kB.
Initial chunk files | Names | Raw size | Estimated transfer size
main-Y7NFKKDI.js | main | 165.19 kB | 45.43 kB
polyfills-FFHMD2TL.js | polyfills | 34.52 kB | 11.28 kB
styles-Q6XDLNNY.css | styles | 44 bytes | 44 bytes
| Initial total | 199.75 kB | 56.76 kB
Application bundle generation complete. [1.382 seconds]
Run Angular zone-less
Since our application is using change detection strategy "OnPush" in all details and we are using Angular-V19,
we can run the application zone-less. This is done by changing app.config.ts
to:
import { ApplicationConfig, provideExperimentalZonelessChangeDetection, provideZoneChangeDetection } from '@angular/core';
export const appConfig: ApplicationConfig = {
providers: [
provideExperimentalZonelessChangeDetection(),
]
};
And by removing the zone-polyfill from angular.json
. Please remove the following line from angular.json
:
"polyfills": [
"zone.js"
],
and
"polyfills": [
"zone.js",
"zone.js/testing"
],
With that, the application can be built for production:
Initial chunk files | Names | Raw size | Estimated transfer size
main-EFNPPUUF.js | main | 164.96 kB | 45.38 kB
styles-Q6XDLNNY.css | styles | 44 bytes | 44 bytes
| Initial total | 165.00 kB | 45.42 kB
So, 45.42 kB is the estimated transfer size after all optimizations compared to initial 75.31 kB. This is a perfect result for a small application.
Conclusion
In this article, we have implemented a simple Angular form using FormRecord
and a Control-Value-Accessor (CVA) component.
The form allows the user to select toppings for a pizza. The user can select the kind of pizza and the toppings for each kind of pizza separately.
The form is implemented using FormRecord<FormControl<boolean|null>>
and the CheckboxListComponent
is a simple CVA component.
He have used Angular's typed reactive forms and have used change detection strategy "OnPush" everywhere.
This allows us to run the application zone-less and to reduce the estimated transfer size to 45.42 kB from 75.31 kB initially.
Thanks for your patience and happy coding!