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.

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 the onChanged method.
  • Similar to the registerOnChange method, the component also registers a method for the registerOnTouched method. This method is called when the user has interacted with the form control. The associated method is onTouched and emits on touched-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!