27

It is possible to subscribe a callback to an NgForm's valueChanges observable property in order to react to changes in the values of the controls of the form.

I need, in the same fashion, to react to the event of the user touching one of the form controls.

This class seem to define the valueChanges Observable and the touched property is defined as a boolean.

Is there a way to to react to the "control touched" event?

Chedy2149
  • 2,821
  • 4
  • 33
  • 56

7 Answers7

23

You can extend default FormControl class, and add markAsTouched method that will call native method, plus your side effect.

import { Injectable } from '@angular/core';
import { FormControl, AsyncValidatorFn, ValidatorFn } from '@angular/forms';
import { Subscription, Subject, Observable } from 'rxjs';

export class ExtendedFormControl extends FormControl {
  statusChanges$: Subscription;
  touchedChanges: Subject<boolean> = new Subject<boolean>();

  constructor(
    formState: Object,
    validator: ValidatorFn | ValidatorFn[] = null,
    asyncValidator: AsyncValidatorFn | AsyncValidatorFn[] = null
  ) {
    super(formState, validator, asyncValidator);

    this.statusChanges$ = Observable.merge(
      this.valueChanges,
      this.touchedChanges.distinctUntilChanged()
    ).subscribe(() => {
      console.log('new value or field was touched');
    });
  }

  markAsTouched({ onlySelf }: { onlySelf?: boolean } = {}): void {
    super.markAsTouched({ onlySelf });

    this.touchedChanges.next(true);
  }
}
Eggy
  • 4,052
  • 7
  • 23
  • 39
  • 1
    I also need to subscribe/handle the touched event on my form controls. How do I tell angular to use the extended FormControl in place of the vanilla FormControl class? – Adam May 22 '17 at 21:11
  • @adamisnt Use angular DI system: https://angular.io/docs/ts/latest/guide/dependency-injection.html#injector-providers – Eggy May 23 '17 at 08:04
  • @adamisnt One thing to note is that subscribing in example above may cause memory leaks, since we never unsubscribe. It would be better to just expose an observable and consume it inside a component. – Eggy May 23 '17 at 08:05
  • @Eggy would you mind showing us how to provide the extended FormControl using the Angular DI mechanism? – Stevy Jul 30 '19 at 11:21
  • @Eggy How do we provide ExtendedFormControl using the Angular DI mechanism? – Aniket Apr 10 '23 at 17:45
17

There is not direct way provided by ng2 to react on touched event. It uses (input) event to fire the valueChanges event and (blur) event to set touched/untouched property of AbstractControl. So you need to manually subscribe on desired event in the template and handle it in your component class.

Valikhan Akhmedov
  • 961
  • 1
  • 10
  • 14
13

Had this same issue - put together this helper method to extract an observable which you can subscribe to in a form to be notified when touched status changes:

// Helper types

/**
 * Extract arguments of function
 */
export type ArgumentsType<F> = F extends (...args: infer A) => any ? A : never;

/**
 * Creates an object like O. Optionally provide minimum set of properties P which the objects must share to conform
 */
type ObjectLike<O extends object, P extends keyof O = keyof O> = Pick<O, P>;


/**
 * Extract a touched changed observable from an abstract control
 * @param control AbstractControl like object with markAsTouched method
 */
export const extractTouchedChanges = (control: ObjectLike<AbstractControl, 'markAsTouched' | 'markAsUntouched'>): Observable<boolean> => {
  const prevMarkAsTouched = control.markAsTouched.bind(control);
  const prevMarkAsUntouched = control.markAsUntouched.bind(control);

  const touchedChanges$ = new Subject<boolean>();

  function nextMarkAsTouched(...args: ArgumentsType<AbstractControl['markAsTouched']>) {
    prevMarkAsTouched(...args);
    touchedChanges$.next(true);
  }

  function nextMarkAsUntouched(...args: ArgumentsType<AbstractControl['markAsUntouched']>) {
    prevMarkAsUntouched(...args);
    touchedChanges$.next(false);
  }
  
  control.markAsTouched = nextMarkAsTouched;
  control.markAsUntouched = nextMarkAsUntouched;

  return touchedChanges$;
}
// Usage (in component file)

...
    this.touchedChanged$ = extractTouchedChanges(this.form);
...

I then like to do merge(this.touchedChanged$, this.form.valueChanges) to get an observable of all changes required to update validation.

*Edit - on @marked-down's suggestion I've moved the call to the previous function to before emitting the new value, in case you query directly after receiving the value and end up out of sync

mbdavis
  • 3,861
  • 2
  • 22
  • 42
  • I like the idea, but aren't you changing the prototype when you do it like this? Doesn't this trigger side-effects everywhere? – Joep Kockelkorn Feb 28 '20 at 16:01
  • 2
    A bit late to this - but no you're only affecting the instance of the form that you pass to the helper – mbdavis May 13 '20 at 20:27
  • 1
    A minor addendum to this: I swapped the order of the bind call to the previous function and the emission of a value in the subject in the new `markAsTouched`/`markAsUntouched` functions, as if you're listening to `touchChanged$` events and decide to query `control.touched`—you may get into a state where the value of the emission is `true` and the `control.touched` property is still `false`. This can be avoided by emitting after calling the existing `markAs(Un)touched` functions. – marked-down Oct 10 '21 at 22:02
  • that's a good point actually @marked-down - will update my answer – mbdavis Oct 11 '21 at 08:02
5

I've solved this way:

this.control['_markAsTouched'] = this.control.markAsTouched;
this.control.markAsTouched = () => {
  this.control['_markAsTouched']();
  // your event handler
}

basically i'm overwriting the default markAsTouched method of FormControl.

ʞᴉɯ
  • 5,376
  • 7
  • 52
  • 89
4

If your issue was anything like mine, I was trying to mark a field as touched in one component and then respond to that in another component. I had access to the AbstractControl for that field. The way I got around it was

field.markAsTouched();
(field.valueChanges as EventEmitter<any>).emit(field.value);

And then I just subscribed to valueChanges in my other component. Noteworthy: field.valueChanges is exported as an Observable, but at runtime it's an EventEmitter, making this a less than beautiful solution. The other limitation of this would obviously be the fact that you're subscribing to a lot more than just the touched state.

  • 1
    Thanks for the trick! There is an open feature request to extend the forms API with more event emitters and I referenced your answer as a temporary workaround: https://github.com/angular/angular/issues/10887#issuecomment-481729918 – almeidap Apr 10 '19 at 15:08
1

Extended solution posted by @ʞᴉɯ

const form = new FormControl('');
(form as any)._markAsTouched = form.markAsTouched;
(form as any).touchedChanges = new Subject();
form.markAsTouched = opts => {
  (form as any)._markAsTouched(opts);
  (form as any).touchedChanges.next('touched');
}; 

...

(form as any).touchedChanges.asObservable().subscribe(() => {
  // execute something when form was marked as touched
});
lilith
  • 11
  • 2
  • Your answer could be improved with additional supporting information. Please [edit] to add further details, such as citations or documentation, so that others can confirm that your answer is correct. You can find more information on how to write good answers [in the help center](/help/how-to-answer). – Community Aug 25 '23 at 08:28
0

Here's the util function I came up with, it also listens for reset method and makes control untouched:

/**
 * Allows to listen the touched state change.
 * The util is needed until Angular allows to listen for such events.
 * Https://github.com/angular/angular/issues/10887.
 * @param control Control to listen for.
 */
export function listenControlTouched(
  control: AbstractControl,
): Observable<boolean> {
  return new Observable<boolean>(observer => {
    const originalMarkAsTouched = control.markAsTouched;
    const originalReset = control.reset;

    control.reset = (...args) => {
      observer.next(false);
      originalReset.call(control, ...args);
    };

    control.markAsTouched = (...args) => {
      observer.next(true);
      originalMarkAsTouched.call(control, ...args);
    };

    observer.next(control.touched);

    return () => {
      control.markAsTouched = originalMarkAsTouched;
      control.reset = originalReset;
    };
  });
}