Angular async validator on reactive forms with keystroke throttling and real time password strength meter

In this tutorial we are going to learn how to implement an Angular async validator to validate a password field with a call to a backend service, while also throttling user keystrokes and showing on a progress bar how good the password is.

Some familiarity with npm and Angular is assumed here :)

Setup

Create a new angular project by running

1
2
3
4
5
# if you don't have angular cli, install it first
# npm install -g @angular/cli
ng new async-thrott-val
cd async-thrott-val
ng add @angular/material

For the questions that will pop up during project generation, default should be fine.

Creating the form

Lets add a password input field, a progress bar for showing how good the chosen password is and a button to our app.component.hml. Erase everything you find in said file and paste this

1
2
3
4
5
6
7
8
9
10
11
12
13
<div class="container">
  <form [formGroup]="form">
    <mat-form-field class="form-field">
      <input matInput formControlName="password" type="password" />
      <mat-error *ngIf="form.controls.password.errors">Weak password</mat-error>
    </mat-form-field>
    <mat-progress-bar
      [value]="passScoreBar$ | async"
      [color]="form.controls.password.errors && form.controls.password.touched ? 'warn': 'primary'"
    ></mat-progress-bar>
    <button mat-raised-button type="submit" color="primary">Submit</button>
  </form>
</div>

Add some styling to app.component.css just to see things better

1
2
3
4
5
6
7
8
9
10
11
12
13
14
.container {
  height: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
}

form {
  width: 500px;
}

.form-field {
  width: 100%;
}

In app.module.ts you’ll also need to add the proper material and reactive form modules

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@NgModule({
  declarations: [AppComponent],
  imports: [
    BrowserModule,
    BrowserAnimationsModule,
    ReactiveFormsModule,
    MatProgressBarModule,
    MatFormFieldModule,
    MatInputModule,
    MatButtonModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule {}

In AppComponent, paste the base for our reactive form:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { Component, ChangeDetectionStrategy, OnInit } from "@angular/core";
import { FormGroup, FormBuilder } from "@angular/forms";
import { Observable } from "rxjs";

@Component({
  selector: "app-root",
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.css"],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class AppComponent implements OnInit {
  form: FormGroup;
  passScoreBar$: Observable<number>;

  constructor(private fb: FormBuilder) {}

  ngOnInit(): void {
    this.form = this.fb.group({
      password: [""]
    });
  }
}

Everything should be runnable with ng serve. If it does not work, check all the steps again.

Creating our backend service and validator

Our backend service will be used to validate a score for a given password. To simplify this tutorial, we are not really going to call a real back end. Instead, we are going to implement a mock service that will just evaluate a password based on its length. Our validator will then call this service.

Create a new service by running ng g s password. The code for it will be

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { Injectable } from "@angular/core";
import { Observable, of } from "rxjs";
import { map, delay, tap } from "rxjs/operators";

@Injectable({
  providedIn: "root"
})
export class PasswordService {
  // define the minimum safe score for a password to be used
  static MIN_PASSWORD_SCORE = 8;

  constructor() {}

  // return a password score
  getPasswordScore(password: string | undefined): Observable<number> {
    password = password || "";

    return of(password.length).pipe(
      // output to console to show that our 'request' is running
      tap(_ => console.log("going to request backend for ", password))
    );
  }
}

Now let’s create our validator. Create a file inside the app folder called async-pass.validator.ts and paste this inside:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { Injectable } from "@angular/core";
import {
  AbstractControl,
  AsyncValidator,
  ValidationErrors
} from "@angular/forms";
import { Observable, of, Subject, timer } from "rxjs";
import { map, switchMap, tap } from "rxjs/operators";
import { PasswordService } from "./password.service";

@Injectable({ providedIn: "root" })
export class AsyncPassValidator implements AsyncValidator {
  constructor(private pwService: PasswordService) {}

  validate(
    control: AbstractControl
  ): Promise<ValidationErrors | null> | Observable<ValidationErrors | null> {
    return this.pwService.getPasswordScore(control.value).pipe(
      map(score => {
        // if password score is below threshold, validation will fail
        if (score < PasswordService.MIN_PASSWORD_SCORE) {
          return { unsafe: true };
        }
        // otherwise, no errors
        return null;
      })
    );
  }
}

and update your app.component.ts by adding the async password validator to the form builder code:

1
2
3
4
5
6
7
8
9
[...]
  constructor(private fb: FormBuilder, private pwValidator: AsyncPassValidator) {}

  ngOnInit(): void {
    this.form = this.fb.group({
      password: ['', [], [this.pwValidator.validate.bind(this.pwValidator)]]
    });
  }
[...]

Now if you run this example with ng s, you should see that when inputting a password and then ‘submitting’ the form, an error will appear under the input box. If you open the browser inspection tools, you will also see that for every key stroke, a new ‘request’ is being made to the backend (as a console log output), which would spam our server (if we were communicating with one) with requests. Also, the user has no active feedback on how good the password he is typing is. So let’s add some throttling and feedback with that progress bar we already have in our html file.

Adding throttling and password score feedback

Angular async validators will run either immediately for every keystroke, on form submit, or when the input field loses focus, depending on how you configure the validator. We are going to reach a middle point here, were we react to every keypress, but throttle user input so that we don’t spam the server, while still allowing feedback about what was input into the field without having to submit or lose input focus.

Change the validator’s validate implementation to only start fetching from service after 500ms

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
  validate(
    control: AbstractControl
  ): Promise<ValidationErrors | null> | Observable<ValidationErrors | null> {
    return timer(500).pipe(
      switchMap(_ => this.pwService.getPasswordScore(control.value)),
      map(score => {
        // if password score is below threshold, validation will fail
        if (score < PasswordService.MIN_PASSWORD_SCORE) {
          return { unsafe: true };
        }
        // otherwise, no errors
        return null;
      })
    );
  }

What will happen here is that with a new control value change, our FormControl will unsubscribe from the previous Observable that was awaiting completion. Therefore, the old Observable will not emit a value, hence no backend call will be made. This logic can be better understood with an image:

keystroke
keystroke
timer(500)
timer(500)
AsyncValidator
AsyncV...
FormControl (app.component.ts)
FormControl (app.component.ts)
subscribe
subscribe
password score (with unsubscription)
password s...
pipe
pipe
emit
emit
password
service
call
password...
new keystroke
(less than 500ms)
new keystroke...
timer(500)
timer(500)
unsubscribe
unsubscribe
subscribe
subscribe
Viewer does not support full SVG 1.1

Now we just need to show how good the password is while the user types his password. For that, we are going to emit a score value as soon as a call is made to our “backend”, which can then be used as a value in the progress bar. We need to export a Subject which will emit the score to be read from app.component.ts.

Let’s change the validate function again to emit a value every time a request is successfully sent to the backend. For that, add a private variable to AsyncPassValidator:

1
  private scoreEmitter$ = new Subject<number>();

and then also create a getter for this subject:

1
2
3
4
  get score(): Observable<number> {
    return this.scoreEmitter$;
  }

Finally, every time a score is returned from our backend, we will emit it using rxjss tap operator. Right after switchMap(_ => this.pwService.getPasswordScore(control.value)), introduce a new line with the tap operator:

1
2
      switchMap(_ => this.pwService.getPasswordScore(control.value)),
      tap(score => this.scoreEmitter$.next(score)),

Now in our app.component.ts, inside ngOnInit and after the initialization of our form code block, we will insert some code to use the value we emitted from the validator as input for our passScoreBar$. We will need to normalize the password score (which ranges from 0 to 20) to a value that can be used for MatProgressBar, which ranges from 0 to 100.

1
2
3
4
5
6
7
8
9
10
11
12
this.passScoreBar$ = this.pwValidator.score.pipe(
  map(score => {
    // first, normalize value for minimum needed password strength.
    score =
      score > PasswordService.MIN_PASSWORD_SCORE
        ? PasswordService.MIN_PASSWORD_SCORE
        : score;

    // then, normalize for progress bar value
    return score * (100 / PasswordService.MIN_PASSWORD_SCORE);
  })
);

And that should do the trick! If you head over to http://localhost:4200 you should see the progress bar updating after the keystrokes stop for longer than 500ms.

Wrapping up

It can be very frustrating having to enter a lot of data into a form just to find out later on submit that something is missing or wrong. Asynchronous validations are great for giving real time feedback for users when they are typing something into a form that is going to be submitted. Hopefully this can help you implement a similar validation while also sparing your precious server resources to not be fully spammed by the user :) If you want to check the code for this tutorial, head over to the Github repository.

Martin Reus
Author
Martin Reus Writing down what I learn enables me to spread the knowledge - and quite honestly, helps me remember stuff :)