Displaying Angular FormControl Errors in the Template
Angular’s reactive forms populate FormControl.errors on every value change, but rendering those errors safely means guarding each message behind touched, dirty, and submission state so the user sees a message only when it is relevant — this page shows the exact template wiring that pairs an Angular errors object with the native Constraint Validation API baseline this site standardises on.
When to Use This Template-Bound Error Approach
Reach for direct *ngIf-guarded error rendering when the messages live next to their inputs and the validators are synchronous or lightly async. It is the default for reactive forms and pairs cleanly with the Angular Cross-Field Password-Match Validator when a group-level error also needs a home in the template.
- Use this when each field owns its own error region and you want per-control control over
touched/dirtygating. - Prefer a centralised error summary component instead when you must move focus management after a failed submit to a single list at the top of the form.
- If your errors originate from a schema rather than Angular
Validators, see Zod schema validation for shaping messages before they reach the template.
Rendering FormControl.errors with *ngIf and the novalidate Baseline
The template below binds a reactive FormGroup, ships <form novalidate>, and drives final validity through a single manual checkValidity() / reportValidity() call on the native form element. Angular’s per-control errors object handles the inline messaging; the Constraint Validation API is the last gate before submit.
import { Component, ElementRef, ViewChild } from '@angular/core';
import {
FormBuilder,
Validators,
ReactiveFormsModule,
} from '@angular/forms';
@Component({
selector: 'app-signup',
standalone: true,
imports: [ReactiveFormsModule],
template: `
<!-- novalidate: suppress the browser's own bubbles; we call reportValidity() ourselves -->
<form novalidate #formEl (ngSubmit)="onSubmit(formEl)">
<label for="email">Email</label>
<input id="email" type="email" formControlName="email"
[attr.aria-invalid]="isInvalid('email') ? 'true' : null"
aria-describedby="email-error" />
<!-- error region: rendered only once the control is touched AND invalid -->
<p id="email-error" class="field-error" role="alert"
*ngIf="isInvalid('email')">
<ng-container *ngIf="email.errors?.['required']">Email is required.</ng-container>
<ng-container *ngIf="email.errors?.['email']">Enter a valid email address.</ng-container>
</p>
<button type="submit">Create account</button>
</form>
`,
})
export class SignupComponent {
private fb = new FormBuilder();
form = this.fb.group({
email: ['', [Validators.required, Validators.email]],
});
// Convenience getter so the template stays terse.
get email() {
return this.form.controls.email;
}
// A control shows its error only after the user has interacted with it.
isInvalid(name: 'email'): boolean {
const c = this.form.controls[name];
return c.invalid && (c.touched || c.dirty);
}
onSubmit(formEl: HTMLFormElement): void {
// Angular already knows validity; the native API is the single manual gate.
if (!formEl.checkValidity()) {
formEl.reportValidity(); // surfaces the first native failure to the user
this.form.markAllAsTouched(); // reveal every inline Angular error at once
return;
}
// ...proceed with the valid payload
}
}
markAllAsTouched() is what flips every guarded *ngIf on at submit time, so fields the user never focused still reveal their message.
Error-State Gating: Property and Option Reference
These are the control-state flags and template hooks this recipe reads to decide whether a message renders.
| Option | Type | Default | Purpose |
|---|---|---|---|
control.errors |
ValidationErrors | null |
null |
Map of failed validator keys (e.g. required, email) to their payloads. |
control.touched |
boolean |
false |
True once the control has been blurred; primary gate for showing errors. |
control.dirty |
boolean |
false |
True once the value has changed; secondary gate for eager display. |
control.invalid |
boolean |
false |
True when any validator fails; combined with touched/dirty. |
updateOn |
'change' | 'blur' | 'submit' |
'change' |
When Angular recomputes errors; 'blur' reduces error flicker while typing. |
markAllAsTouched() |
() => void |
n/a | Forces every control’s touched to true, revealing all guarded messages on submit. |
Verification Steps
import { test, expect } from '@playwright/test';
test('required error appears only after blur', async ({ page }) => {
await page.goto('/signup');
await expect(page.locator('#email-error')).toHaveCount(0);
await page.locator('#email').focus();
await page.locator('#email').blur();
await expect(page.locator('#email-error')).toContainText('Email is required.');
await expect(page.locator('#email')).toHaveAttribute('aria-invalid', 'true');
});
Edge Cases & Failure Modes
Errors flash on every keystroke. With the default updateOn: 'change', errors recomputes as the user types, so a half-typed address shows the email error mid-word. Fix by setting updateOn: 'blur' on the control so validation — and thus the guarded message — only fires when the field loses focus.
Async validators render a stale or empty message. While an async validator is in flight the control is pending, not invalid, so an *ngIf="control.invalid" region briefly renders nothing. Gate the region with control.pending for a spinner and only show the message once resolution completes; see asynchronous server checks for the debounced validator that feeds this state.
The message is invisible to screen readers. An error <p> that is not associated with its input, or that is injected without a live region, is announced inconsistently. Wire aria-describedby to the error element’s id and give the element role="alert", per accessible error messaging, then confirm with axe-core accessibility testing.
Driving the Error Region with statusChanges and OnPush
Reading control.errors directly in the template works, but under ChangeDetectionStrategy.OnPush the view only re-renders when an input reference changes, an event fires, or an observable bound with the async pipe emits. A form built with OnPush for performance can leave a stale error visible because the plain *ngIf="isInvalid('email')" expression is only re-evaluated when change detection happens to run for another reason. The robust fix is to derive the message from the control’s own statusChanges stream so the async pipe marks the view dirty exactly when validity flips.
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { FormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { AsyncPipe, NgIf } from '@angular/common';
import { map, startWith } from 'rxjs/operators';
@Component({
selector: 'app-signup',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ReactiveFormsModule, AsyncPipe, NgIf],
template: `
<form [formGroup]="form" novalidate>
<input id="email" type="email" formControlName="email" />
<!-- emailError$ recomputes only when the control's status changes -->
<p class="field-error" role="alert" *ngIf="emailError$ | async as message">
{{ message }}
</p>
</form>
`,
})
export class SignupComponent {
private fb = new FormBuilder();
form = this.fb.group({
email: ['', [Validators.required, Validators.email]],
});
// statusChanges fires on every VALID/INVALID/PENDING transition.
emailError$ = this.form.controls.email.statusChanges.pipe(
startWith(this.form.controls.email.status), // seed the initial render
map(() => {
const c = this.form.controls.email;
if (c.valid || (!c.touched && !c.dirty)) return null;
if (c.errors?.['required']) return 'Email is required.';
if (c.errors?.['email']) return 'Enter a valid email address.';
return null;
}),
);
}
The single emailError$ observable collapses the gating logic and the key-to-message mapping into one place, and because it is bound through async the OnPush view updates deterministically. This scales better than one getter call per validator key: a form with a dozen controls does not re-run a dozen template expressions on every unrelated change-detection tick.
Migrating a Getter-Based Template to a Signal
Angular’s signals give a synchronous, glitch-free alternative to the statusChanges observable and read more naturally in a template than an async pipe. If you are already on a recent Angular version, toSignal converts the status stream into a signal you can call directly, and a computed signal derives the message. The template no longer needs the async pipe at all, and the value is always current at read time.
import { Component, computed } from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms';
import { toSignal } from '@angular/core/rxjs-interop';
export class SignupComponent {
private fb = new FormBuilder();
form = this.fb.group({ email: ['', [Validators.required, Validators.email]] });
// Mirror the control's status into a signal so computed() can track it.
private status = toSignal(this.form.controls.email.statusChanges, {
initialValue: this.form.controls.email.status,
});
emailError = computed(() => {
const c = this.form.controls.email;
void this.status(); // establish the reactive dependency on status
if (c.valid || (!c.touched && !c.dirty)) return null;
if (c.errors?.['required']) return 'Email is required.';
if (c.errors?.['email']) return 'Enter a valid email address.';
return null;
});
}
Call emailError() in the template — <p *ngIf="emailError() as message">{{ message }}</p>. The trade-off is that touched and dirty are not themselves signals, so the void this.status() line is what forces the computed to recompute; the status transition is the observable event that ultimately carries the touched/dirty change through on blur. When you later adopt Angular’s signal forms this indirection disappears entirely.
Frequently Asked Questions
Why does my Angular error message show before the user touches the field?
The control is invalid from the moment it initialises, so an unguarded *ngIf="control.invalid" renders immediately. Gate it with control.touched || control.dirty so the message only appears after interaction, and call markAllAsTouched() on submit to reveal the rest.
How do I map each validator key to a different message?
Read the specific key off the errors object, for example control.errors?.['minlength'], and give each key its own ng-container. For native constraints you can instead surface custom validity messages through setCustomValidity().
Should I still use novalidate if Angular already validates?
Yes. novalidate stops the browser's own error bubbles from competing with your Angular templates, while a single manual reportValidity() call keeps the native focus-and-announce behaviour on submit. This is the site's canonical baseline for every form.
Related Guides
- Angular Reactive Forms Validation — the parent overview for validators, form groups, and control state.
- Angular Cross-Field Password-Match Validator — where group-level errors need their own template region alongside per-control ones, covering cross-field validation.
- React Hook Form Validation — the equivalent error-rendering model in a React codebase.
- Vue VeeValidate Validation — how the same touched-gating idea maps onto VeeValidate’s slot props.