Template-Driven Forms
Before you start
You need: routing (Article 05) and binding (Article 02).
Time: about 40 minutes, plus the practice.
Learning objective
Build a validated form using template-driven syntax, display errors accessibly, and judge when reactive forms are the better choice.
Topics
- Setup and
ngModel ngFormand form state- Validation directives
- Control state: touched, dirty, valid
- Displaying errors accessibly
- Select, radio and checkbox
- Submitting
- Custom validation
- When to use reactive forms instead
Setup
import { FormsModule, NgForm } from '@angular/forms';
@Component({
selector: 'app-student-form',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './student-form.component.html'
})
export class StudentFormComponent {
model: StudentInput = {
name: '',
rollNumber: '',
className: '',
section: '',
dateOfBirth: '',
parentName: '',
parentPhone: '',
address: null
};
}
Initialise every field. An undefined property bound with ngModel produces a control Angular cannot track, and the field silently never validates.
ngModel and ngForm
<form #studentForm="ngForm" (ngSubmit)="onSubmit(studentForm)" novalidate>
<div class="field">
<label for="name">Student name</label>
<input id="name" name="name" type="text"
[(ngModel)]="model.name"
required maxlength="100"
#name="ngModel">
</div>
<button type="submit">Save</button>
</form>
Three pieces:
| Piece | Purpose |
|---|---|
#studentForm="ngForm" | A reference to the whole form's state |
name="name" | Required — registers the control with the form |
#name="ngModel" | A reference to this control's state |
name is mandatory. ngModel inside a <form> without it throws at runtime:
If ngModel is used within a form tag, either the name attribute must be set or the form control must be defined as 'standalone' in ngModelOptions.
novalidate on the form disables the browser's own validation UI, so Angular's messages are the only ones shown. Without it, the browser's bubble appears and Angular's markup is never reached.
Validation directives
<input name="rollNumber" type="text"
[(ngModel)]="model.rollNumber"
required
pattern="^NCA-\d{4}-\d{4}$"
maxlength="20"
#rollNumber="ngModel">
<input name="parentPhone" type="tel"
[(ngModel)]="model.parentPhone"
required
pattern="^[6-9]\d{9}$"
maxlength="10"
inputmode="numeric"
#parentPhone="ngModel">
<input name="marks" type="number"
[(ngModel)]="model.marks"
required min="0" max="100"
#marks="ngModel">
| Directive | Error key |
|---|---|
required | required |
minlength / maxlength | minlength / maxlength |
pattern | pattern |
min / max | min / max |
email | email |
Angular reimplements these as directives so they participate in its validation system — the HTML attributes alone would only drive the browser's UI.
type="tel" rather than type="number" for a phone number: number strips leading zeros, permits e, and shows spinner arrows. inputmode="numeric" still gives the numeric keypad on mobile.
Control state
| Property | True when |
|---|---|
valid / invalid | Validation passes / fails |
pristine / dirty | Unchanged / changed by the user |
untouched / touched | Never blurred / has been blurred |
pending | An async validator is running |
errors | An object of failed validators, or null |
<p>Form valid: {{ studentForm.valid }}</p>
<p>Name errors: {{ name.errors | json }}</p>
Show an error only when the user has touched the field or tried to submit. Flagging a required field as invalid before the user has typed anything is hostile:
@if (name.invalid && (name.touched || studentForm.submitted)) {
<p class="error">Student name is required.</p>
}
studentForm.submitted covers the case where the user clicks Save without touching a field — otherwise the form refuses to submit and shows no reason.
Displaying errors accessibly
<div class="field">
<label for="rollNumber">Roll number</label>
<input id="rollNumber" name="rollNumber" type="text"
[(ngModel)]="model.rollNumber"
required pattern="^NCA-\d{4}-\d{4}$" maxlength="20"
placeholder="NCA-2024-0012"
[attr.aria-invalid]="rollNumber.invalid && (rollNumber.touched || studentForm.submitted)"
[attr.aria-describedby]="'rollNumberHelp rollNumberError'"
#rollNumber="ngModel">
<small id="rollNumberHelp">Format: NCA-2024-0012</small>
@if (rollNumber.invalid && (rollNumber.touched || studentForm.submitted)) {
<p id="rollNumberError" class="error" role="alert">
@if (rollNumber.errors?.['required']) {
Roll number is required.
} @else if (rollNumber.errors?.['pattern']) {
Roll number must look like NCA-2024-0012.
}
</p>
}
</div>
Three things make this accessible:
aria-invalidtells a screen reader the field is in error.aria-describedbylinks the hint and the message, so both are read after the label.role="alert"announces the message the moment it appears.
Colour alone is not enough. A red border is invisible to a colour-blind user; the text message is what conveys the problem.
Styling from control state:
input.ng-invalid.ng-touched { border-color: var(--colour-danger); }
input.ng-valid.ng-touched { border-color: var(--colour-success); }
Angular adds ng-valid, ng-invalid, ng-touched, ng-untouched, ng-dirty and ng-pristine automatically. Always pair ng-invalid with ng-touched, or every empty required field is red on load.
Select, radio and checkbox
<div class="field">
<label for="className">Class</label>
<select id="className" name="className" [(ngModel)]="model.className" required
#className="ngModel">
<option value="">-- Select class --</option>
@for (option of classOptions; track option) {
<option [value]="option">{{ option }}</option>
}
</select>
</div>
The empty first option is what makes required meaningful. Without it the first real option is preselected and the field can never be empty.
Use [ngValue] rather than [value] when the option is an object:
<option [ngValue]="teacher">{{ teacher.name }}</option>
[value] stringifies, so an object becomes [object Object].
<fieldset>
<legend>Section</legend>
@for (section of ['A', 'B', 'C']; track section) {
<label [for]="'section' + section">
<input [id]="'section' + section" type="radio" name="section"
[value]="section" [(ngModel)]="model.section" required>
{{ section }}
</label>
}
</fieldset>
Radio buttons sharing one name is what makes them mutually exclusive. <fieldset> and <legend> group them so a screen reader announces "Section, A" rather than an unexplained "A".
<label for="isHosteller">
<input id="isHosteller" type="checkbox" name="isHosteller" [(ngModel)]="model.isHosteller">
Hostel resident
</label>
A checkbox binds a boolean directly — unlike a plain HTML form, where an unchecked box submits nothing.
Submitting
export class StudentFormComponent {
private readonly studentService = inject(StudentService);
private readonly router = inject(Router);
saving = signal(false);
serverError = signal<string | null>(null);
onSubmit(form: NgForm): void {
if (form.invalid) {
form.control.markAllAsTouched();
this.focusFirstInvalid();
return;
}
this.saving.set(true);
this.serverError.set(null);
this.studentService.create(this.model).subscribe({
next: () => {
this.router.navigate(['/students'], { queryParams: { saved: '1' } });
},
error: (err: HttpErrorResponse) => {
this.saving.set(false);
if (err.status === 400 && err.error?.errors) {
this.applyServerErrors(err.error.errors);
return;
}
if (err.status === 409) {
this.serverError.set('This roll number is already in use.');
return;
}
this.serverError.set('Could not save. Please try again.');
}
});
}
private focusFirstInvalid(): void {
const firstInvalid = document.querySelector<HTMLElement>('.ng-invalid[name]');
firstInvalid?.focus();
}
}
<button type="submit" [disabled]="saving()">
{{ saving() ? 'Saving…' : 'Save' }}
</button>
<button type="button" (click)="cancel()">Cancel</button>
@if (serverError()) {
<p class="error" role="alert">{{ serverError() }}</p>
}
Four details:
markAllAsTouched() makes every error visible at once. Without it, a user who clicks Save on an untouched form sees nothing happen.
type="button" on Cancel. A <button> inside a form defaults to type="submit", so Cancel would submit.
[disabled]="saving()" prevents a double-click creating two students.
Focus the first invalid field, so a keyboard user is taken to the problem rather than having to hunt for it.
Mapping server errors back to fields:
private applyServerErrors(errors: Record<string, string[]>): void {
for (const [field, messages] of Object.entries(errors)) {
const control = this.form.controls[this.toCamelCase(field)];
if (control && messages[0]) {
control.setErrors({ server: messages[0] });
control.markAsTouched();
}
}
}
ASP.NET Core returns a ValidationProblemDetails errors object keyed by property name. Mapping it to controls gives field-level messages rather than one generic banner.
Client validation is a convenience, never a control. Every directive here is removable in DevTools, and the request can be sent without the page. The server validates independently.
Custom validation
A directive, because template-driven validation is directive-based:
import { Directive, Input } from '@angular/core';
import { AbstractControl, NG_VALIDATORS, ValidationErrors, Validator } from '@angular/forms';
@Directive({
selector: '[appMinAge]',
standalone: true,
providers: [{ provide: NG_VALIDATORS, useExisting: MinAgeDirective, multi: true }]
})
export class MinAgeDirective implements Validator {
@Input('appMinAge') minAge = 3;
validate(control: AbstractControl): ValidationErrors | null {
if (!control.value) {
return null; // let `required` handle absence
}
const dateOfBirth = new Date(control.value);
const age = new Date().getFullYear() - dateOfBirth.getFullYear();
return age >= this.minAge ? null : { minAge: { required: this.minAge, actual: age } };
}
}
<input name="dateOfBirth" type="date" [(ngModel)]="model.dateOfBirth"
required [appMinAge]="3" #dateOfBirth="ngModel">
@if (dateOfBirth.errors?.['minAge']) {
<p class="error" role="alert">
Student must be at least {{ dateOfBirth.errors!['minAge'].required }} years old.
</p>
}
multi: true is essential — without it this validator replaces every other registered validator rather than joining them.
Returning null for an empty value avoids duplicating required, so an omitted field produces one message rather than two.
An async validator for a server check:
@Directive({
selector: '[appUniqueRollNumber]',
standalone: true,
providers: [{ provide: NG_ASYNC_VALIDATORS, useExisting: UniqueRollNumberDirective, multi: true }]
})
export class UniqueRollNumberDirective implements AsyncValidator {
private readonly studentService = inject(StudentService);
validate(control: AbstractControl): Observable<ValidationErrors | null> {
if (!control.value) {
return of(null);
}
return timer(400).pipe(
switchMap(() => this.studentService.rollNumberExists(control.value)),
map(exists => exists ? { rollNumberTaken: true } : null),
catchError(() => of(null))
);
}
}
timer(400) debounces, so it does not query on every keystroke. switchMap cancels the previous request. catchError returning null means a network failure does not block the form — the server check on submit is the real guarantee.
While it runs, control.pending is true:
@if (rollNumber.pending) {
<small role="status">Checking availability…</small>
}
When to use reactive forms instead
| Template-driven | Reactive | |
|---|---|---|
| Structure defined in | The template | The class |
| Validation | Directives | Functions |
| Dynamic fields | Awkward | Straightforward |
| Unit testing | Needs the DOM | Plain object |
| Value changes | Hard to observe | valueChanges observable |
| Type safety | Weak | Strong with typed forms |
| Best for | Short, static forms | Everything else |
Template-driven suits a login form or a five-field search. Beyond that — dynamic fields, cross-field rules, conditional validation, anything needing tests — reactive forms are less work, not more.
Mixing both approaches in one form is not supported. Choose one per form.
Errors you will hit
| Message | Cause | Fix |
|---|---|---|
Can't bind to 'ngModel' | FormsModule not imported | Import it |
| Validation messages show immediately | Not checking touched or dirty | Guard on them |
| The form submits while invalid | No check on form.valid | Check it, and disable the button |
ngModel needs a name attribute | Missing name inside a form | Add it |
| The server still rejects a valid-looking form | Client validation is not the control | Validate on the server too |
Client-side validation is a convenience. Postman bypasses every rule here, which is why the API validates again.
Common mistakes
- Missing
nameon anngModelcontrol inside a form - An uninitialised model property, so the control never validates
- No
novalidate, so the browser's UI competes with Angular's - Errors shown before the field is touched
- No
markAllAsTouched(), so submitting an untouched form appears to do nothing - Cancel without
type="button" - No
disabledon submit, allowing a double-click [value]instead of[ngValue]for an object option- No empty first
<option>, sorequiredon a select does nothing type="number"for a phone numberng-invalidstyled withoutng-touched- Missing
multi: trueon a custom validator - Colour as the only error signal
- Trusting client validation as a control
- Template-driven forms for a large dynamic form
Practice
The course exercise is create a validated form.
- Build the student form with
ngModel,required,patternandmaxlengthon every relevant field. - Remove
namefrom one control. Record the runtime error. - Remove
novalidateand submit an invalid form. Compare the browser's message with Angular's. - Show errors without checking
touched. Load the page and observe every field red. - Add
touched || submittedand confirm the improvement. - Click Save on an untouched invalid form without
markAllAsTouched(). Confirm nothing appears, then add it. - Add
aria-invalid,aria-describedbyandrole="alert". Test with a screen reader. - Remove
type="button"from Cancel and click it. - Remove
[disabled]="saving()"and double-click Save. Confirm two students. - Build a select with an object option using
[value], then[ngValue]. Compare. - Remove the empty first
<option>and confirmrequiredno longer fires. - Write
MinAgeDirectivewithmulti: true. Removemultiand observe every other validator stop working. - Write the async unique-roll-number validator with debounce and cancellation. Show the pending state.
- In DevTools, delete
requiredfrom an input and submit. Confirm the server still rejects it. - Add server-error mapping from a 400
errorsobject to individual controls.
Exercises 6, 9 and 14 correspond to three real defects — an unresponsive form, a duplicate record, and a false sense of validation.
You can now
- Build a validated template-driven form
- Show messages only after a field is touched
- Disable submit while the form is invalid
- Say why the server must validate again
- Make error messages accessible
Review questions
- Why is
namemandatory on anngModelcontrol inside a form? - Why check
touched || submittedbefore showing an error? - What does
multi: truedo on a custom validator provider? - When are reactive forms the better choice?
Next: Reactive forms