Unified Control State Change Events - working with reactive form is never the same in Angular

Connie Leung - Jun 23 - - Dev Community

Introduction

In this blog post, I want to describe a new Angular 18 feature called unified control state change events that listen to events emitted from form groups and form controls. These events return an Observable that can pipe to different RxJS operators to achieve the expected results. Then, the Observable can resolve in an inline template by async pipe.

Form Group's events

  • FormSubmittedEvent - It fires when a form submit occurs
  • FormResetEvent - It fires when a form is reset

Form Control's events

  • PristineChangeEvent - It fires when a form control changes from the pristine status to the dirty status
  • TouchedChangeEvent - It firs when a form control changes from untouched to touched and vice versa.
  • StatusChangeEvent - It fires when a form control's status is updated (valid, invalid, pending, and disabled).
  • ValueChangeEvent - It fires when a form control updates its value.

I will demonstrate some examples of the events that I did in a Stackblitz demo.

Boostrap Application

// app.config.ts

export const appConfig: ApplicationConfig = {
  providers: [
    provideExperimentalZonelessChangeDetection()
  ]
};
Enter fullscreen mode Exit fullscreen mode
// main.ts

import { appConfig } from './app.config';

bootstrapApplication(App, appConfig);
Enter fullscreen mode Exit fullscreen mode

Bootstrap the component and the application configuration to start the Angular application.

Form Group Code

The form group has two fields, name and email, and a nested company form group. Moreover, it has a button to submit form data and another button to reset the form.

The CompanyAddressComponent is consisted of company name, address line 1, address line 2, and city.

// company-address.component.ts

import { ChangeDetectionStrategy, Component, OnInit, inject } from "@angular/core";
import { FormGroup, FormGroupDirective, ReactiveFormsModule } from "@angular/forms";

@Component({
  selector: 'app-company-address',
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <div [formGroup]="formGroup">
      <div>
        <label for="companyName">
          <span>Company Name: </span>
          <input id="companyName" name="companyName" formControlName="name">
        </label>
      </div>
      <div>
        <label for="address">
          <span>Company Address Line 1: </span>
          <input id="line1" name="line1" formControlName="line1">
        </label>
      </div>
      <div>
        <label for="line2">
          <span>Company Address Line 2: </span>
          <input id="line2" name="line2" formControlName="line2">
        </label>
      </div>
      <div>
        <label for="city">
          <span>Company City: </span>
          <input id="city" name="city" formControlName="city">
        </label>
      </div>
    </div>
  `,
  styles: `
    :host {
      display: block;
    }
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CompanyAddressComponent implements OnInit {
  formGroupDir = inject(FormGroupDirective);
  formGroup!: FormGroup<any>;

  ngOnInit(): void {
    this.formGroup = this.formGroupDir.form.get('company') as FormGroup;
  }
}
Enter fullscreen mode Exit fullscreen mode
// reactive-form.util.ts

export function makeRequiredControl(defaultValue: any) {
  return new FormControl(defaultValue, {
    nonNullable: true, 
    validators: [Validators.required],
    updateOn: 'blur'
  });
}
Enter fullscreen mode Exit fullscreen mode
// main.ts

<div class="container">
      <h1>Angulara Version: {{ version }}!</h1>
      <h3>Form Unified Control State Change Events</h3>
      <h4>Type Pikachu in the name field to trigger valueChanges</h4>

      <form [formGroup]="formGroup" (reset)="resetMyForm($event)" (submit)="formSubmit.next()">
        <div>
          <label for="name">
            <span [style.color]="isNamePristine$ | async"
              [style.fontWeight]="isNameTouched$ | async"
            >Name: </span>
            <input id="name" name="name" formControlName="name">
          </label>
        </div>
        <div>
          <label for="email">
            <span>Email: </span>
            <input id="email" name="email" formControlName="email">
          </label>
        </div>
        <app-company-address />
        <div>
          <button type="submit">Submit</button>
          <button type="reset">Reset</button>
        </div>
      </form>

      <div>
        @if (fields$ | async; as fields) {
          <p>
          Number of completed fields: {{ fields.completed }},
          Percentage: {{ fields.percentage }}
          </p>
        }
        @if (isPikachu$ | async; as isPikachu) {
          <p>Pikachu is my favorite Pokemon.</p>
        }
        @if(formReset$ | async; as formReset) {
          <p>Form reset occurred at {{ formReset.timestamp }}. Form reset occurred {{ formReset.count }} times.</p>
        }
        @if(formSubmit$ | async; as formSubmit) {
          <p>Form submit occurred at {{ formSubmit.timestamp }}.</p>
          <pre>Form Values: {{ formSubmit.values | json }}</pre>
        }
      <div>
  <div>`,

formGroup = new FormGroup({
    name: makeRequiredControl('Test me'),
    email: new FormControl('', {
      nonNullable: true,
      validators: [Validators.email, Validators.required],
      updateOn: 'blur',
    }),  
    company: new FormGroup({
      name: makeRequiredControl(''),
      line1: makeRequiredControl(''),
      line2: makeRequiredControl(''),
      city: makeRequiredControl(''),
    })
  });
Enter fullscreen mode Exit fullscreen mode

Example 1: Track the last submission time and form values using FormSubmittedEvent

I would like to know the last time that the form was submitted. When the form is valid, the form values are also displayed.

// main.ts

 <form [formGroup]="formGroup" (submit)="formSubmit.next()">...</form>

formSubmit = new Subject<void>();

formSubmit$ = this.formGroup.events.pipe(
    filter((e) => e instanceof FormSubmittedEvent),
    map(({ source }) => ({ 
      timestamp: new Date().toISOString(),
      values: source.valid ? source.value: {} 
    })),
  );
Enter fullscreen mode Exit fullscreen mode

The submit emitter emits a value to the formSubmit subject. formSubmit$ Observable filters the events to obtain an instance of FormSubmittedEvent. When the form has valid values, the values are returned. Otherwise, an empty Object is returned. The Observable finally emits the time of submission and valid form values.

@if(formSubmit$ | async; as formSubmit) {
     <p>Form submit occurred at {{ formSubmit.timestamp }}.</p>
     <pre>Form Values: {{ formSubmit.values | json }}</pre>
 }
Enter fullscreen mode Exit fullscreen mode

Async pipe resolves formSubmit$ in the template to display the timestamp and JSON object.

Example 2: Track number of times a form is reset using FormResetEvent

// main.ts

 <form [formGroup]="formGroup" (reset)="resetMyForm($event)" >...</form>

 formReset$ = this.formGroup.events.pipe(
    filter((e) => e instanceof FormResetEvent),
    map(() => new Date().toISOString()),
    scan((acc, timestamp) => ({
        timestamp,
        count: acc.count + 1,
      }), { timestamp: '', count: 0 }),
  );

  resetMyForm(e: Event) {
    e.preventDefault();
    this.formGroup.reset();
  }
Enter fullscreen mode Exit fullscreen mode

The reset emitter invokes the resetMyForm method to reset the form. The formReset$ Observable filters the events to obtain an instance of FormResetEvent. The Observable uses the map operator to produce the reset timestamp and the scan operator to count the number of occurrences. The Observable finally emits the time of reset and the number of resets.

@if(formReset$ | async; as formReset) {
    <p>Form reset occurred at {{ formReset.timestamp }}. Form reset occurred {{ formReset.count }} times.</p>
}
Enter fullscreen mode Exit fullscreen mode

In the template, async pipe resolves formReset$ to display the timestamp and the count.

Example 3: Update the label color when name field is dirty

I want to change the label of the name field to blue when it is dirty.

// main.ts

formControls = this.formGroup.controls;
isNamePristine$ = this.formControls.name.events.pipe(
    filter((e) => e instanceof PristineChangeEvent)
    map((e) => e as PristineChangeEvent),
    map((e) => e.pristine),
    map((pristine) => pristine ? 'black' : 'blue'),
 )
Enter fullscreen mode Exit fullscreen mode

isNamePristine$ Observable filters the events of the name control to obtain an instance of PristineChangeEvent. The Observable uses the first map operator to cast the ControlEvent to PristineChangeEvent. When the field is not dirty, the label color is black. Otherwise, the label color is blue.

// main.ts

<span [style.color]="isNamePristine$ | async">Name: </span>
Enter fullscreen mode Exit fullscreen mode

In the template, async pipe resolves isNamePristine$ to update the color of the span element.

Example 4: Update the font weight when name field is touched

// main.ts

formControls = this.formGroup.controls;
isNameTouched$ = this.formControls.name.events.pipe(
    filter((e) => e instanceof TouchedChangeEvent),
    map((e) => e as TouchedChangeEvent),
    map((e) => e.touched),
    map((touched) => touched ? 'bold' : 'normal'),
  )  
Enter fullscreen mode Exit fullscreen mode

isNameTouched$ Observable filters the events of the name control to obtain an instance of TouchedChangeEvent. The Observable uses the first map operator to cast the ControlEvent to TouchedChangeEvent. When the field is touched, the label is bold. Otherwise, the label is normal.

// main.ts

<span [style.fontWeight]="isNameTouched$ | async"></span>
Enter fullscreen mode Exit fullscreen mode

In the template, async pipe resolves isNameTouched$ to update the font weight of the span element.

Example 5: Track the progress of a nested form using StatusChangeEvent

// control-status.operator.ts

import { ControlEvent, StatusChangeEvent } from "@angular/forms"
import { Observable, filter, map, shareReplay, startWith } from "rxjs"

export function controlStatus(initial = 0) {
  return (source: Observable<ControlEvent<unknown>>) => {
    return source.pipe(
      filter((e) => e instanceof StatusChangeEvent),
      map((e) => e as StatusChangeEvent),
      map((e) => e.status === 'VALID' ? 1 : 0),
      startWith(initial),
      shareReplay(1)
    )
  }
}
Enter fullscreen mode Exit fullscreen mode

This custom RxJS operator filters the form control events to obtain an instance of StatusChangeEvent. When the form control is valid, the operator emits 1, otherwise it returns 0.

// main.ts

 numFields = countTotalFields(this.formGroup);

formControls = this.formGroup.controls;
companyControls = this.formControls.company.controls;
isEmailValid$ = this.formControls.email.events.pipe(controlStatus());
isNameValid$ = this.formControls.name.events.pipe(controlStatus(1));
isCompanyNameValid$ = this.companyControls.name.events.pipe(controlStatus());
isLine1Valid$ = this.companyControls.line1.events.pipe(controlStatus());
isLine2Valid$ = this.companyControls.line2.events.pipe(controlStatus());
isCityValid$ = this.companyControls.city.events.pipe(controlStatus());

fields$ = combineLatest([
    this.isEmailValid$, 
    this.isNameValid$,
    this.isCompanyNameValid$,
    this.isLine1Valid$,
    this.isLine2Valid$,
    this.isCityValid$,
  ])
    .pipe(
      map((validArray) => {
        const completed = validArray.reduce((acc, item) => acc + item);
        return {
          completed,
          percentage: ((completed / validArray.length) * 100).toFixed(2)
        }
      }),
    );
Enter fullscreen mode Exit fullscreen mode

This is not the most efficient method but I construct an Observable for each form control in the nested form. Then, I pass these Observable to the combineLatest operator to calculate the number of completed fields. Moreover, the validArray has all the control status; therefore, validArray.length equals to the total number of form controls. I can use the information to derive the percent of completion and return the result in a JSON object.

// main.ts

@if (fields$ | async; as fields) {
     <p>
         Number of completed fields: {{ fields.completed }},
         Percentage: {{ fields.percentage }}
     </p>
}
Enter fullscreen mode Exit fullscreen mode

In the template, async pipe resolves fields$ to display the number of completed fields and the percent of completion.

The following Stackblitz repo displays the final results:

This is the end of the blog post that describes the unified control change events in reactive form †in Angular 18. I hope you like the content and continue to follow my learning experience in Angular, NestJS, GenerativeAI, and other technologies.

Resources:

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .