Angular Forms Validation: Part III - Async Validators gotchas
I am about to continue sharing my expertise on Angular’s Forms Validation. In this forthcoming article, we will examine common pitfalls and challenges that arise when utilizing async validators.
I. Absence of Error Message in UI after Async Validation Completion
This is a common issue that many developers encounter when working with async validators. Interestingly, it has been an open issue on the Angular GitHub repository for over three and a half years. This problem is not limited to just the OnPush change detection strategy but also affects the Default strategy. Let’s explore a scenario where you are likely to encounter this problem.
To demonstrate the issue, let’s declare a simple component with the Default change detection strategy:
@Component({...})
export class SingleControlComponent {
readonly usernameControl = new FormControl(null,
{ asyncValidators: userDoesNotExist, updateOn: 'blur' });
}
function userDoesNotExist(control: FormControl): Observable<ValidationErrors | null> {
console.log('Starting async validation...')
const result$ = control.value !== 'test'
// 'delay' is used to simulate server call
? of({'user-does-not-exist': true}).pipe(delay(1000))
: of(null).pipe(delay(1000));
return result$.pipe(tap(result => console.log(result)));
}
The component consists of a single FormControl
with an Async Validator function that emulates checking the availability of a username. The template markup for the component is as follows:
<label for="username">Username</label>
<input name="username" type="text" [formControl]="usernameControl" />
<div class="errors">
<span *ngIf="usernameControl.hasError('user-does-not-exist')"
>Username in not used</span
>
</div>
After executing this code, the following behavior can be observed: Let’s break down the behavior observed into stages:
- The user enters a valid value ‘test’, in the field and then moves the focus away from it. (NOTE: The
updateOn: 'blur'
configuration is used to prevent multiple validation calls.) Upon doing so, the console displays messages indicating the start and completion of the validation process without any errors. So far, everything is working as expected. - The user updates the value to an invalid one, such as ‘test1’. Once again, messages about the start and completion of validation appear in the console. Since the validation fails, the console displays
{user-does-not-exist: true}
. However, at the UI level, no errors are displayed. - The user interacts with the field by focusing on it and then moving the focus away. This triggers a change detection, resulting in the UI being updated. (NOTE: In the case of the OnPush change detection strategy, change detection will not be triggered by this interaction, and the UI will remain in an outdated state. In such cases, manual triggering of change detection is necessary.)
Indeed, to address this issue, we need to explicitly inform Angular to run change detection when we have the validation result. Adding the following code snippet to our component will achieve precisely that:
...
constructor(cd: ChangeDetectorRef) {
this.usernameControl.statusChanges.subscribe(() => cd.markForCheck());
}
...
Now, with the addition of the code snippet, the behavior aligns with our expectations, and the issue has been resolved:
II. Async Validators start simultaneously on parent and child
There are situations where we need to asynchronously validate not just the value of a single FormControl
, but the entire FormGroup
. Angular provides this functionality, but unfortunately, not everything goes as expected. Below, we will demonstrate two common problems that you are likely to encounter.
II.A Status of the parent not updated as expected
In some cases, when performing async validation, we may want to show a progress indication or lock form controls in the UI to inform the user. Angular’s AbstractFormControl
(and his descendants FormControl
and FormGroup
) provides a useful observable property for such scenarios: statusChanges
. The value of this property becomes PENDING
when an async validation is in progress. Let’s take a look at a demo for this scenario.
Here we have a simple component with a FormGroup
and an async validator applied to the group.
...
Component({...})
export class ParentChildStatusComponent {
constructor() {
this.form = new FormGroup({
'username': new FormControl(null, [Validators.required]),
'password': new FormControl(null, [])
}, { updateOn: 'blur', asyncValidators: this.passwordAlreadyUsed.bind(this) });
}
private passwordAlreadyUsed(control: FormGroup): Observable<ValidationErrors | null> { ... }
}
NOTE: Some code related to displaying the validation progress has been omitted for simplicity.
The markup for this component is as follows:
<div class="form" [formGroup]="form">
<label for="username">Username</label>
<input name="username" type="text" formControlName="username" />
<div class="errors">
<span *ngIf="form.get('username').hasError('required')"
>Username is Required</span
>
</div>
<label for="password">Password</label>
<input name="password" type="text" formControlName="password" />
<div class="progress validator">
USERNAME ASYNC VALIDATOR STATUS: {{usernameValidatorStatus$ | async}}
</div>
<div class="progress validator">
PASSWORD ASYNC VALIDATOR STATUS: {{passwordValidatorStatus$ | async}}
</div>
<div class="progress">
FORM STATUS IS {{(form.statusChanges | async) || form.status}}
</div>
</div>
Let’s take a look at the output during the execution of this demo:
- The form has a single synchronous validator (
Validators.required
) applied to the ‘username’ FormControl. Initially, the form is in the ‘INVALID’ state and no asynchronous validators are running or executed. - The user enters a value in the username field and then moves the focus away from it (the form has the
updateOn: 'blur'
option set). After that, the synchronous validator is executed, and the result is valid. Then, the asynchronous validator of the FormGroup starts executing, and the FormGroup’s status becomesPENDING
. The validator is in theSTARTED
state. - Once the validation is completed, the
FormGroup
’s status becomesVALID
. Everything is progressing smoothly and exactly as expected.
Now, let’s add an additional asynchronous validator to the ‘username’ FormControl
and observe how it affects the form’s behavior.
...
Component({...})
export class ParentChildStatusComponent {
constructor() {
this.form = new FormGroup({
'username': new FormControl(null, [Validators.required], [this.userDoesNotExist.bind(this)]),
'password': new FormControl(null, [])
}, { updateOn: 'blur', asyncValidators: this.passwordAlreadyUsed.bind(this) });
}
private userDoesNotExist(control: FormControl): Observable<ValidationErrors | null> { ... }
private passwordAlreadyUsed(control: FormGroup): Observable<ValidationErrors | null> { ... }
}
Let’s examine the user interface of the form after incorporating this small enhancement in form validation. Although the user interface appears similar, we have encountered a problem in the form validation.
- Initially, the form is in an
INVALID
state with no asynchronous validators running or executed. - When the user enters a value in the username field and moves the focus away, the synchronous validator completes. After that, both the asynchronous validator of the ‘username’
FormControl
and the asynchronous validator of theFormGroup
start executing. As a result, theFormGroup
status becomesPENDING
and both validators are indicated asSTARTED
in the console. So far so good. - However, after the validation for the ‘username’
FormControl
is completed and theFormGroup
status changes toVALID
, we encounter an issue. The asynchronous validator of theFormGroup
is still running, which leads to an incorrect status for the form. Consequently, we cannot rely on the form status for locking the user interface or displaying progress indications. This is a disappointing discovery.
Therefore, the current status of the form is incorrect, and we cannot depend on it for locking the user interface or displaying progress indications. This limitation is indeed disappointing, as it hinders our ability to provide accurate feedback to the user.
II.B A synchronous validator failure does not prevent async validator from triggering
Let’s take a look at another example of async validator problems. This one is going to be the last one, but definitely not the least. Suppose we want to make the password field required in our password setting form. We can achieve this by applying the Validators.required
validator to the ‘password’ FormContorl
.
...
Component({...})
export class ParentChildStatusComponent {
constructor() {
this.form = new FormGroup({
'username': new FormControl(null, [Validators.required], [this.userDoesNotExist.bind(this)]),
'password': new FormControl(null, [Validators.required])
}, { updateOn: 'blur', asyncValidators: this.passwordAlreadyUsed.bind(this) });
}
private userDoesNotExist(control: FormControl): Observable<ValidationErrors | null> { ... }
private passwordAlreadyUsed(control: FormGroup): Observable<ValidationErrors | null> { ... }
}
And now we expect the passwordAlreadyUsed
async validator to be executed only after all sync validators have passed. This behavior is explicitly mentioned in Angular’s documentation:
It is important to note that the asynchronous validation happens after the synchronous validation, and is performed only if the synchronous validation is successful. This check allows forms to avoid potentially expensive async validation processes such as an HTTP request if more basic validation methods fail.
However, the form validation behaves differently in this case. The form goes through the following stages:
- The form is initially in an
INVALID
state , and no async validators are running or executed. - The user edits the ‘username’ field and moves the focus away from it. The synchronous validation successfully completes, and then the asynchronous validator for this control starts executing.
- However, something unexpected happens. The asynchronous validator
passwordAlreadyUsed
starts running! , even though the ‘password’FormControl
is invalid.
After the user fills out the form and all the validations are completed, the resulting form state is correct. However, we notice that there are unnecessary calls to the server due to the unexpected execution of the passwordAlreadyUsed
async validator.
Angular’s documentation likely meant the order of execution for sync and async validators for the same FormControl
or FormGroup.
However, in this case, we have a hierarchy of validators that seems to unexpectedly deviate.
Conclusion
Thank you for reading. I hope this article has provided you with valuable insights and saved you time in understanding how Angular’s forms behave. It’s important to note that while the actual behavior may differ from what you expect, understanding these nuances will help you work more effectively with Angular’s forms. You can find all the code samples used in this article at the following location Github. If you have any questions or need further clarification, feel free to reach out. Happy coding!
Links to the previous articles: Angular Forms Validation: Part I - Single control validation. Angular Forms Validation: Part II - FormGroup validation.