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()
]
};
// main.ts
import { appConfig } from './app.config';
bootstrapApplication(App, appConfig);
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;
}
}
// reactive-form.util.ts
export function makeRequiredControl(defaultValue: any) {
return new FormControl(defaultValue, {
nonNullable: true,
validators: [Validators.required],
updateOn: 'blur'
});
}
// 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(''),
})
});
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: {}
})),
);
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>
}
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();
}
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>
}
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'),
)
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>
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'),
)
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>
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)
)
}
}
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)
}
}),
);
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>
}
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.
Top comments (3)
Hi Connie Leung,
Top, very nice and helpful !
Thanks for sharing. ✅
FWIW that’s probably not the easiest way of dealing with forms around 😅 Angular, what are you doing to us…
Me too, I cannot choose between template-driven and reactive forms in Angular.