Skip to content

Fix: Angular Form Validation Not Working — Validators Not Triggering

FixDevs ·

Quick Answer

How to fix Angular form validation not working — Reactive Forms vs Template-Driven, custom validators, async validators, touched/dirty state, and error message display.

The Problem

Angular form validators are set up but don’t show errors:

this.form = this.fb.group({
  email: ['', [Validators.required, Validators.email]],
});
// form.invalid is true but no error message shows in the template

Or form.get('email')?.errors is null even when the field is empty:

// In the component
console.log(this.form.get('email')?.errors);  // null
// Despite email being empty and required validator being set

Or a custom validator never fires:

// Custom validator applied to the FormControl
email: ['', myCustomValidator]
// myCustomValidator never runs

Or the form submits even though validation fails:

<form [formGroup]="form" (ngSubmit)="onSubmit()">
  <button type="submit">Submit</button>
  <!-- Form submits even when form.invalid is true -->
</form>

Why This Happens

Angular has two form approaches — Reactive Forms and Template-Driven Forms — with different APIs. Mixing them or using the wrong approach causes validators to silently fail:

  • Module not imported — Reactive Forms require ReactiveFormsModule; Template-Driven requires FormsModule. Using FormGroup without importing ReactiveFormsModule causes all validation to silently fail.
  • Error shown before user interaction — by default, Angular shows a control as invalid from the start (before the user touches it). If the template checks hasError without checking touched or dirty, the UX shows errors immediately on page load.
  • Custom validator wrong return value — validators must return { [key: string]: any } | null. Returning false or undefined instead of null for valid inputs causes the control to always appear invalid.
  • Async validator not returning an Observable or Promise — async validators must return Observable<ValidationErrors | null> or Promise<ValidationErrors | null>.
  • Template variable used incorrectly — using #email as a template reference variable without ="ngModel" doesn’t give access to the control’s validation state.
  • ngSubmit not preventing default — in Template-Driven forms, the form still submits HTML-natively if Angular’s submit handling isn’t wired correctly.

Fix 1: Import the Right Module

Reactive Forms and Template-Driven Forms each need their own module:

// app.module.ts (NgModule approach)
import { ReactiveFormsModule, FormsModule } from '@angular/forms';

@NgModule({
  imports: [
    ReactiveFormsModule,  // For Reactive Forms (FormGroup, FormControl, FormBuilder)
    FormsModule,          // For Template-Driven Forms (ngModel, ngForm)
  ],
})
export class AppModule {}
// Standalone component (Angular 14+)
import { Component } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';

@Component({
  standalone: true,
  imports: [ReactiveFormsModule],  // Import in the component itself
  template: `...`,
})
export class LoginComponent {}

Verify you’re using the right form approach:

ApproachDirectiveModule
Reactive Forms[formGroup], formControlName, [formControl]ReactiveFormsModule
Template-DrivenngModel, ngForm, ngModelGroupFormsModule

Mixing [formGroup] with ngModel on the same form is a common mistake that breaks validation.

Fix 2: Show Errors Only After User Interaction

Validators run from the start, but showing errors before the user interacts is bad UX. Check touched or dirty:

<!-- BAD — shows error immediately on page load -->
<input formControlName="email" />
<div *ngIf="form.get('email')?.errors?.['required']">Email is required</div>

<!-- GOOD — only show after user has touched the field -->
<input formControlName="email" />
<div *ngIf="form.get('email')?.errors?.['required'] && form.get('email')?.touched">
  Email is required
</div>

<!-- OR: show all errors after the user touches the field -->
<input formControlName="email" (blur)="onBlur()" />
<ng-container *ngIf="form.get('email')?.invalid && form.get('email')?.touched">
  <div *ngIf="form.get('email')?.errors?.['required']">Email is required</div>
  <div *ngIf="form.get('email')?.errors?.['email']">Invalid email format</div>
  <div *ngIf="form.get('email')?.errors?.['minlength']">
    Minimum {{ form.get('email')?.errors?.['minlength'].requiredLength }} characters
  </div>
</ng-container>

Show all errors on submit attempt:

// Mark all fields as touched when submitting invalid form
onSubmit() {
  if (this.form.invalid) {
    this.form.markAllAsTouched();  // Shows all validation errors
    return;
  }
  // Process form submission
}
<form [formGroup]="form" (ngSubmit)="onSubmit()">
  <input formControlName="email" />
  <!-- Errors show after submit attempt (touched) OR after user interacts -->
  <div *ngIf="form.get('email')?.invalid && form.get('email')?.touched">
    <span *ngIf="form.get('email')?.errors?.['required']">Required</span>
    <span *ngIf="form.get('email')?.errors?.['email']">Invalid email</span>
  </div>
  <button type="submit">Submit</button>
</form>

Fix 3: Write Custom Validators Correctly

A validator function must return ValidationErrors | null:

// WRONG — returns false for invalid (should return error object)
function noSpaces(control: AbstractControl): ValidationErrors | null {
  if (control.value.includes(' ')) {
    return false;  // ← Wrong — must return an object or null
  }
  return null;
}

// WRONG — returns undefined for valid (should return null)
function noSpaces(control: AbstractControl) {
  if (control.value.includes(' ')) {
    return { noSpaces: true };
  }
  // Returns undefined implicitly — should return null
}
// CORRECT — returns error object or null
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';

// Simple validator
function noSpacesValidator(control: AbstractControl): ValidationErrors | null {
  if (control.value && /\s/.test(control.value)) {
    return { noSpaces: true };   // Key used in template: errors?.['noSpaces']
  }
  return null;   // null = valid
}

// Validator with parameter (factory function)
function minAgeValidator(minAge: number): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    const age = parseInt(control.value, 10);
    if (isNaN(age) || age < minAge) {
      return { minAge: { required: minAge, actual: age } };
    }
    return null;
  };
}

// Cross-field validator (on the FormGroup, not FormControl)
function passwordMatchValidator(group: AbstractControl): ValidationErrors | null {
  const password = group.get('password')?.value;
  const confirm = group.get('confirmPassword')?.value;

  if (password !== confirm) {
    return { passwordMismatch: true };
  }
  return null;
}

// Apply validators
this.form = this.fb.group({
  username: ['', [Validators.required, noSpacesValidator]],
  age: ['', [Validators.required, minAgeValidator(18)]],
  password: ['', Validators.required],
  confirmPassword: ['', Validators.required],
}, { validators: passwordMatchValidator });  // ← Group-level validator

Access group-level validation errors in the template:

<!-- Group-level errors are on the form, not individual controls -->
<div *ngIf="form.errors?.['passwordMismatch'] && form.get('confirmPassword')?.touched">
  Passwords do not match
</div>

Fix 4: Write Async Validators Correctly

Async validators check values against an API (username availability, email uniqueness):

// WRONG — doesn't return Observable
function checkUsernameAvailability(control: AbstractControl) {
  this.userService.checkUsername(control.value).subscribe(isAvailable => {
    if (!isAvailable) {
      return { usernameTaken: true };  // ← Wrong: returned inside subscribe, not from the validator
    }
  });
  // Returns undefined — async validator must return Observable/Promise
}
// CORRECT — returns Observable<ValidationErrors | null>
import { Injectable } from '@angular/core';
import { AbstractControl, AsyncValidatorFn, ValidationErrors } from '@angular/forms';
import { Observable, of } from 'rxjs';
import { map, catchError, debounceTime, switchMap, first } from 'rxjs/operators';

@Injectable({ providedIn: 'root' })
export class UsernameValidator {
  constructor(private userService: UserService) {}

  checkAvailability(): AsyncValidatorFn {
    return (control: AbstractControl): Observable<ValidationErrors | null> => {
      if (!control.value) return of(null);

      return of(control.value).pipe(
        debounceTime(400),         // Wait 400ms after last keystroke
        switchMap(username =>
          this.userService.checkUsername(username).pipe(
            map(isAvailable => isAvailable ? null : { usernameTaken: true }),
            catchError(() => of(null)),  // Don't block form on API error
          )
        ),
        first(),  // Complete after one emission
      );
    };
  }
}

// Apply async validator
this.form = this.fb.group({
  username: ['',
    [Validators.required, Validators.minLength(3)],  // Sync validators
    [this.usernameValidator.checkAvailability()],    // Async validators (3rd arg)
  ],
});

Show async validation state:

<input formControlName="username" />
<div *ngIf="form.get('username')?.pending">Checking availability...</div>
<div *ngIf="form.get('username')?.errors?.['usernameTaken']">Username is taken</div>

The pending state is true while the async validator is running.

Fix 5: Fix Template-Driven Form Validation

Template-Driven Forms use ngModel — access validation state through template reference variables:

<!-- WRONG — #email is a reference to the input element, not the NgModel -->
<input type="email" name="email" ngModel #email />
<div *ngIf="email.invalid">Invalid</div>
<!-- email.invalid doesn't exist on HTMLInputElement -->

<!-- CORRECT — assign the NgModel directive to the reference variable -->
<input type="email" name="email" ngModel #email="ngModel" required email />
<div *ngIf="email.invalid && email.touched">
  <span *ngIf="email.errors?.['required']">Email is required</span>
  <span *ngIf="email.errors?.['email']">Invalid email format</span>
</div>

Built-in HTML5 validators as Angular validators:

<!-- These HTML attributes are recognized as Angular validators when FormsModule is imported -->
<input name="username" ngModel required minlength="3" maxlength="20" />
<input name="age" ngModel type="number" min="18" max="100" />
<input name="email" ngModel type="email" />  <!-- 'email' type validates format -->
<input name="website" ngModel pattern="https?://.+" />

Disable native HTML5 validation (Angular replaces it):

<form ngForm novalidate (ngSubmit)="onSubmit(f)" #f="ngForm">
  <!-- 'novalidate' disables browser's native validation — Angular handles it -->
</form>

Fix 6: Disable Submit Button When Form Is Invalid

Prevent submission of an invalid form directly in the template:

<form [formGroup]="form" (ngSubmit)="onSubmit()">
  <!-- Input fields -->
  <input formControlName="email" type="email" />

  <!-- Disable button while form is invalid OR while submitting -->
  <button
    type="submit"
    [disabled]="form.invalid || isSubmitting"
  >
    {{ isSubmitting ? 'Submitting...' : 'Submit' }}
  </button>
</form>
isSubmitting = false;

async onSubmit() {
  if (this.form.invalid) {
    this.form.markAllAsTouched();
    return;
  }

  this.isSubmitting = true;
  try {
    await this.service.submit(this.form.value);
    // Handle success
  } catch (err) {
    // Handle error
  } finally {
    this.isSubmitting = false;
  }
}

Fix 7: Reusable Form Error Component

Avoid repeating error display logic with a reusable component:

// shared/form-error/form-error.component.ts
@Component({
  selector: 'app-form-error',
  standalone: true,
  imports: [CommonModule],
  template: `
    <ng-container *ngIf="control && control.invalid && (control.touched || showAll)">
      <p class="error" *ngIf="control.errors?.['required']">This field is required.</p>
      <p class="error" *ngIf="control.errors?.['email']">Enter a valid email address.</p>
      <p class="error" *ngIf="control.errors?.['minlength']">
        Minimum {{ control.errors?.['minlength'].requiredLength }} characters required.
      </p>
      <p class="error" *ngIf="control.errors?.['maxlength']">
        Maximum {{ control.errors?.['maxlength'].requiredLength }} characters allowed.
      </p>
      <p class="error" *ngIf="control.errors?.['pattern']">Invalid format.</p>
      <p class="error" *ngIf="control.errors?.['usernameTaken']">Username is already taken.</p>
      <p class="error" *ngIf="control.errors?.['passwordMismatch']">Passwords do not match.</p>
    </ng-container>
  `,
})
export class FormErrorComponent {
  @Input() control: AbstractControl | null = null;
  @Input() showAll = false;  // Show errors even if not touched
}
<!-- Usage -->
<form [formGroup]="form" (ngSubmit)="onSubmit()">
  <input formControlName="email" />
  <app-form-error [control]="form.get('email')" />

  <input formControlName="username" />
  <app-form-error [control]="form.get('username')" />

  <button type="submit">Submit</button>
</form>

Still Not Working?

Check the FormControl status directly:

const emailControl = this.form.get('email');
console.log('value:', emailControl?.value);
console.log('valid:', emailControl?.valid);
console.log('errors:', emailControl?.errors);
console.log('touched:', emailControl?.touched);
console.log('dirty:', emailControl?.dirty);
console.log('status:', emailControl?.status);  // 'VALID', 'INVALID', 'PENDING', 'DISABLED'

updateOn option affects when validation runs:

// By default, validation runs on every value change (updateOn: 'change')
// 'blur' — runs only when the control loses focus
// 'submit' — runs only when the form is submitted
this.form = this.fb.group({
  email: ['', { validators: [Validators.email], updateOn: 'blur' }],
});

If updateOn: 'submit' is set on the group or a control, validators won’t run until form submission — the control shows as valid until then.

Angular DevTools — inspect the form state in the Component tab. Select the component, expand the form properties, and see all control states in real time.

For related Angular issues, see Fix: Angular Lazy Loading Not Working and Fix: Angular RxJS Memory Leak.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles