Search code examples
javascriptangulartypescriptangular-forms

How to use debounceTime() and distinctUntilChanged() in async validator


I want to add debounceTime and distinctUntilChanged in my async validator.

mockAsyncValidator(): AsyncValidatorFn {
    return (control: FormControl): Observable<ValidationErrors | null> => {
      return control.valueChanges.pipe(
        debounceTime(500),
        distinctUntilChanged(),
        switchMap(value => {
          console.log(value);  // log works here
          return this.mockService.checkValue(value).pipe(response => {
            console.log(response);  // log did not work here
            if (response) {
              return { invalid: true };
            }
            return null;
          })
        })
      );
  }

The code above did not work, the form status becomes PENDING.
But when I use timer in this answer, the code works, but I can't use distinctUntilChanged then.

return timer(500).pipe(
    switchMap(() => {
      return this.mockService.checkValue(control.value).pipe(response => {
        console.log(response);  // log works here
        if (response) {
          return { invalid: true };
        }
        return null;
      })
    })
  );

I tried to use BehaviorSubject like

debouncedSubject = new BehaviorSubject<string>('');

and use it in the AsyncValidatorFn, but still not work, like this:

this.debouncedSubject.next(control.value);
return this.debouncedSubject.pipe(
  debounceTime(500),
  distinctUntilChanged(), // did not work
                          // I think maybe it's because of I next() the value
                          // immediately above
                          // but I don't know how to fix this
  take(1), // have to add this, otherwise, the form is PENDING forever
           // and this take(1) cannot add before debounceTime()
           // otherwise debounceTime() won't work
  switchMap(value => {
    console.log(value); // log works here
    return this.mockService.checkValue(control.value).pipe(response => {
        console.log(response);  // log works here
        if (response) {
          return { invalid: true };
        }
        return null;
      }
    );
  })
);

Solution

  • The problem is that a new pipe is being built every time the validatorFn executes as you are calling pipe() inside the validatorFn. The previous value isn't capture for discinct or debounce to work. What you can do is setup two BehaviourSubjects externally, termDebouncer and validationEmitter in my case.

    You can set up a factory method to create this validator and thereby re-use it. You could also extend AsyncValidator and create a class with DI setup. I'll show the factory method below.

    export function AsyncValidatorFactory(mockService: MockService) { 
      const termDebouncer = new BehaviorSubject('');
      const validationEmitter = new BehaviorSubject<T>(null);
      let prevTerm = '';
      let prevValidity = null;
    
      termDebouncer.pipe(
            map(val => (val + '').trim()),
            filter(val => val.length > 0),
            debounceTime(500),
            mergeMap(term => { const obs = term === prevTerm ? of(prevValidity) : mockService.checkValue(term);
              prevTerm = term; 
              return obs; }),
            map(respose => { invalid: true } : null),
            tap(validity => prevValidity = validity)
        ).subscribe(validity => validationEmitter.next(validity))
    
    
      return (control: AbstractControl) => {
        termDebouncer.next(control.value)
        return validationEmitter.asObservable().pipe(take(2))
      }
    }
    
    

    Edit: This code excerpt is from a use case other than Angular form validation, (React search widget to be precise.) the pipe operators might need changing to fit your use case.

    Edit2: take(1) or first() to ensure that the observable completes after emitting the validation message. asObservable() will ensure that a new observable will be generated on the next call. You might also be able to skip asObservable() and just pipe() as the pipe operator branches the async pipeline and creates a new observable from there onwards. You might have to use take(2) to get past the fact that a behaviourSubject is stateful and holds a value.

    Edit3: Use a merge map to deal with the fact distinctUntilChanged() will cause the observable to not emit and not complete.