Skip to content

useFormTouched

Returns whether a FormGroup has been touched (interacted with) as a signal. The signal updates reactively whenever the form's touched state changes.

Usage

typescript
import { useFormTouched } from 'ng-reactive-utils';

@Component({
  template: `
    <form [formGroup]="form">
      <input formControlName="email" (blur)="onBlur()" />

      @if (isTouched() && form.get('email')?.invalid) {
        <span class="error">Please enter a valid email</span>
      }
    </form>
  `,
})
class ContactFormComponent {
  form = new FormGroup({
    email: new FormControl('', [Validators.required, Validators.email]),
  });

  isTouched = useFormTouched(this.form);

  onBlur() {
    // Form will be marked as touched automatically on blur
  }
}

Parameters

ParameterTypeDefaultDescription
formFormGrouprequiredThe FormGroup to check touched state for

Returns

Signal<boolean> - A readonly signal containing the touched state (true if interacted with)

Notes

  • Merges TouchedChangeEvent events from the FormGroup and all direct child controls — this is necessary because Angular does not propagate TouchedChangeEvent from child controls up to the parent group
  • Uses control.events (not statusChanges) to listen for touched-state changes; statusChanges does not emit on touch changes
  • Returns the group-level form.touched value on each event, so the signal reflects the overall touched state of the form
  • Only tracks direct child controls — grandchild controls inside nested FormGroup or FormArray children are not observed; use useControlTouched on the nested group directly if needed
  • Returns true when markAsTouched() is called on the form or any direct child loses focus

Source

ts
import { Signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { FormGroup, TouchedChangeEvent } from '@angular/forms';
import { filter, map, merge } from 'rxjs';

/**
 * Returns whether a FormGroup has been touched (interacted with) as a signal.
 * The signal updates reactively whenever any control in the form is touched or
 * untouched, including programmatic calls to markAsTouched() / markAsUntouched().
 *
 * @param form - The FormGroup to check touched state for
 * @returns A signal containing the touched state (true if interacted with)
 *
 * @example
 * ```typescript
 * @Component({
 *   template: `
 *     <form [formGroup]="form">
 *       <input formControlName="email" />
 *       @if (isTouched() && form.get('email')?.invalid) {
 *         <span>Please enter a valid email</span>
 *       }
 *     </form>
 *   `
 * })
 * class MyComponent {
 *   form = new FormGroup({
 *     email: new FormControl('', Validators.email)
 *   });
 *   isTouched = useFormTouched(this.form);
 * }
 * ```
 */
export const useFormTouched = (form: FormGroup): Signal<boolean> => {
  // TouchedChangeEvent on a FormGroup only fires when markAsTouched() / markAsUntouched()
  // is called on the form itself — it does not propagate from child control blur events.
  // Merging events from all child controls ensures the signal stays accurate when a user
  // interacts with any field in the form.
  const allControls = [form, ...Object.values(form.controls)];
  const anyTouchedChange$ = merge(...allControls.map((control) => control.events)).pipe(
    filter((event): event is TouchedChangeEvent => event instanceof TouchedChangeEvent),
    map(() => form.touched),
  );

  return toSignal(anyTouchedChange$, { initialValue: form.touched }) as Signal<boolean>;
};

Released under the MIT License.