19

I'm using custom async validator with Angular 4 reactive forms to check if E-Mail address is already taken by calling a backend.

However, Angular calls the validator, which makes request to the server for every entered character. This creates an unnecessary stress on the server.

Is it possible to elegantly debounce async calls using RxJS observable?

import {Observable} from 'rxjs/Observable';

import {AbstractControl, ValidationErrors} from '@angular/forms';
import {Injectable} from '@angular/core';

import {UsersRepository} from '../repositories/users.repository';


@Injectable()
export class DuplicateEmailValidator {

  constructor (private usersRepository: UsersRepository) {
  }

  validate (control: AbstractControl): Observable<ValidationErrors> {
    const email = control.value;
    return this.usersRepository
      .emailExists(email)
      .map(result => (result ? { duplicateEmail: true } : null))
    ;
  }

}
Slava Fomin II
  • 26,865
  • 29
  • 124
  • 202

5 Answers5

27

While @Slava's answer is right. It is easier with Observable :

return (control: AbstractControl): Observable<ValidationErrors> => {
      return Observable.timer(this.debounceTime).switchMap(()=>{
        return this.usersRepository
            .emailExists(control.value)
            .map(result => (result ? { duplicateEmail: true } : null));
      });
}

updated with modern RxJS:

return (control: AbstractControl): Observable<ValidationErrors> => {
    return timer(this.debounceTime).pipe(
        switchMap(()=>this.usersRepository.emailExists(control.value)),
        map(result => (result ? { duplicateEmail: true } : null))
    );
}

Notes:

  • Angular will automatically unsubscribe the returned Observable
  • timer() with one argument will only emit one item
  • since timer emits only one value it does not matter if we use switchMap or flatMap
  • you should consider to use catchError in case that the server call fails
  • angular docs: async-validation
n00dl3
  • 21,213
  • 7
  • 66
  • 76
  • 2
    I am getting an error using this code: "Observable.timer is not a function". Any ideas ? – Tim Sep 14 '17 at 10:11
  • 1
    `import 'rxjs/add/observable/timer';` @Tim – n00dl3 Sep 14 '17 at 10:13
  • Your code works well. I am still not sure I fully understand it. Wasn't the "debounce" operator specifically designed for this, and yet you are not using it ? – Tim Sep 14 '17 at 11:33
  • 1
    You cannot use the debounce operator as everytime a new value arrives for checking a new observable is generated and the old one is unsubscribed. – n00dl3 Sep 14 '17 at 11:48
  • I don't understand: if the debounce will not work, how come the switchMap does? How can switchMap switch among different, unconnected observables? I think that each time Angular calls the function, a new timer and a new switchMap will be created. – Guillermo Prandi Feb 27 '18 at 23:16
  • Sorry to bother. I've found my answer somewhere else: "Since angular 4 has been released, if a new value is sent for checking, the previous Observable will get unsubscribed, [...]". – Guillermo Prandi Feb 27 '18 at 23:21
  • And for those who are curious (me!) here is the link to the quoted text: https://stackoverflow.com/a/45007974/274463 – Forge_7 Jul 16 '18 at 10:54
22

UPDATE RxJS 6.0.0:

import {of, timer} from 'rxjs';
import {map, switchMap} from 'rxjs/operators';


return (control: AbstractControl): Observable<ValidationErrors> => {
  return timer(500).pipe(
    switchMap(() => {
      if (!control.value) {
        return of(null)
      }
                      
      return this.usersRepository.emailExists(control.value).pipe(
        map(result => (result ? { duplicateEmail: true } : null))
      );
    })
  )
}

*RxJS 5.5.0

For everyone who is using RxJS ^5.5.0 for better tree shaking and pipeable operators

import {of} from 'rxjs/observable/of';
import {map, switchMap} from 'rxjs/operators';
import {TimerObservable} from 'rxjs/observable/TimerObservable';


return (control: AbstractControl): Observable<ValidationErrors> => {
  return TimerObservable(500).pipe(
    switchMap(() => {
      if (!control.value) {
        return of(null)
      }
                      
      return this.usersRepository.emailExists(control.value).pipe(
        map(result => (result ? { duplicateEmail: true } : null))
      );
    })
  )
}
SplitterAlex
  • 2,755
  • 2
  • 20
  • 23
  • 2
    This is the best solution out there. I have been banging my head against this for 4 hours and your solution worked straight away. Thank you – Lee Baker Jul 04 '18 at 10:35
  • 1
    Several hours goes by... and here i am. I suppose within time this will be up as most updated Version/answer. Thanks. – LeoPucciBr Sep 28 '18 at 17:37
5

After studying some offered solutions with Observables I found them too complex and decided to use a solution with promises and timeouts. Although blunt, this solution is much simpler to comprehend:

import 'rxjs/add/operator/toPromise';

import {AbstractControl, ValidationErrors} from '@angular/forms';
import {Injectable} from '@angular/core';

import {UsersRepository} from '../repositories/users.repository';


@Injectable()
export class DuplicateEmailValidatorFactory {

  debounceTime = 500;


  constructor (private usersRepository: UsersRepository) {
  }

  create () {

    let timer;

    return (control: AbstractControl): Promise<ValidationErrors> => {

      const email = control.value;

      if (timer) {
        clearTimeout(timer);
      }

      return new Promise(resolve => {
        timer = setTimeout(() => {
          return this.usersRepository
            .emailExists(email)
            .map(result => (result ? { duplicateEmail: true } : null))
            .toPromise()
            .then(resolve)
          ;
        }, this.debounceTime);
      });

    }

  }

}

Here, I'm converting existing observable to promise using toPromise() operator of RxJS. Factory function is used because we need a separate timer for each control.


Please consider this a workaround. Other solutions, which actually use RxJS, are most welcome!

Slava Fomin II
  • 26,865
  • 29
  • 124
  • 202
  • It's easier with observables :`Observable.timer(this.debounceTime).switchMap(()=>this.usersRepository.emailExists(email).map(result => (result ? { duplicateEmail: true } : null)))` as it is unsubscribed if the value change, the http part is never called if a new value pops in... – n00dl3 Jul 13 '17 at 11:56
0

I think your method only delay, not debounce, then find the sample way to archive this result.

import { debounce } from 'lodash';

...

constructor() {
   this.debounceValidate = debounce(this.debounceValidate.bind(this), 1000);
}

debounceValidate(control, resolve) {
   ...//your validator
}

validate (control: AbstractControl): Promise {
  return new Promise(resolve => {
    this.debounceValidate(control, resolve);
  })
}
TmTron
  • 17,012
  • 10
  • 94
  • 142
-2

If you want to implement it using RxJs,you can listen for valueChanges explicitly and apply async validator on it. For e.g.,considering you have reference ref to your abstractControl you can do,

ref.valueChanges.debounceTime(500).subscribe(//value is new value of control
 value=>{this.duplicateValidator.validate(value)//duplicateValidator is ref to validator
                                .then(d => console.log(d))
                                .catch(d=>console.log(d))
        })