Fix: Angular Form Validation Not Working — Validators Not Triggering
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 templateOr 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 setOr a custom validator never fires:
// Custom validator applied to the FormControl
email: ['', myCustomValidator]
// myCustomValidator never runsOr 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 requiresFormsModule. UsingFormGroupwithout importingReactiveFormsModulecauses 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
hasErrorwithout checkingtouchedordirty, the UX shows errors immediately on page load. - Custom validator wrong return value — validators must return
{ [key: string]: any } | null. Returningfalseorundefinedinstead ofnullfor valid inputs causes the control to always appear invalid. - Async validator not returning an Observable or Promise — async validators must return
Observable<ValidationErrors | null>orPromise<ValidationErrors | null>. - Template variable used incorrectly — using
#emailas a template reference variable without="ngModel"doesn’t give access to the control’s validation state. ngSubmitnot 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:
| Approach | Directive | Module |
|---|---|---|
| Reactive Forms | [formGroup], formControlName, [formControl] | ReactiveFormsModule |
| Template-Driven | ngModel, ngForm, ngModelGroup | FormsModule |
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 validatorAccess 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.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: RxJS Not Working — Observable Not Emitting, Memory Leak from Unsubscribed Stream, or Operator Behaving Unexpectedly
How to fix RxJS issues — subscription management, switchMap vs mergeMap vs concatMap, error handling with catchError, Subject types, cold vs hot observables, and Angular async pipe.
Fix: Angular Lazy Loading Not Working — Routes Not Code-Split
How to fix Angular lazy loading not working — loadChildren syntax, standalone components, route configuration mistakes, preloading strategies, and debugging bundle splits.
Fix: Angular RxJS Memory Leak — Subscriptions Not Unsubscribed
How to fix RxJS memory leaks in Angular — unsubscribing from Observables, takeUntilDestroyed, async pipe, subscription management patterns, and detecting leaks with Chrome DevTools.
Fix: Angular Change Detection Not Working — View Not Updating
How to fix Angular change detection issues — OnPush strategy not triggering, async pipe, markForCheck vs detectChanges, zone.js and zoneless patterns, and manual change detection triggers.