DEV Community

Michael Musatov
Michael Musatov

Posted on • Updated on

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)));
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

After executing this code, the following behavior can be observed:
UI updated after focus change
What is going on here? Let's split it into stages

  1. 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.
  2. 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.
  3. 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());
}
...
Enter fullscreen mode Exit fullscreen mode

Now, with the addition of the code snippet, the behavior aligns with our expectations, and the issue has been resolved:
UI updated automatically

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> { ... }
}

Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Let's take a look at the output during the execution of this demo:
Form editing and validation

  1. 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.
  2. 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 becomes PENDING . The validator is in the STARTED state.
  3. Once the validation is completed, the FormGroup's status becomes VALID. 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> { ... }
}
Enter fullscreen mode Exit fullscreen mode

Let's examine the user interface of the form after incorporating this small enhancement in form validation.
Form editing and validation
Although the user interface appears similar, we have encountered a problem in the form validation.

  1. Initially, the form is in an INVALID state with no asynchronous validators running or executed.
  2. 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 the FormGroup start executing. As a result, the FormGroup status becomes PENDING and both validators are indicated as STARTED in the console. So far so good.
  3. However, after the validation for the 'username' FormControl is completed and the FormGroup status changes to VALID, we encounter an issue. The asynchronous validator of the FormGroup 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> { ... }
}
Enter fullscreen mode Exit fullscreen mode

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.
Unexpected async validation
The form goes through the following stages:

  1. The form is initially in an INVALID state , and no async validators are running or executed.
  2. 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.
  3. 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.

Latest comments (5)

Collapse
 
spock123 profile image
Lars Rye Jeppesen

Dude you saved my day!

Collapse
 
van9petryk profile image
van9petryk • Edited

An article need to be updated. I just checked example 1 with ChangeDetectionStrategy.Default in Angular 9 and for me all works fine.
Hovewer, ChangeDetectionStrategy.OnPush need markForCheck()

Collapse
 
musatov profile image
Michael Musatov

Hello! My bad. I forget to comment out manual change detection for the Default strategy. This line this.usernameControl.statusChanges.subscribe(() => cd.markForCheck());. For Angular 9 problem is still actual. Thank you for the feedback!

Collapse
 
renanduart3 profile image
Renan Duarte

This validation can be made with button click as the event trigger? I've been trying , but failing miserably

Collapse
 
musatov profile image
Michael Musatov

Sure it can be { updateOn: 'submit' }. Take a look at angular.io/api/forms/FormControl#c.... Good luck!