Skip to main content
Published / updated

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
  • ngForm and 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:

PiecePurpose
#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">
DirectiveError key
requiredrequired
minlength / maxlengthminlength / maxlength
patternpattern
min / maxmin / max
emailemail

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

PropertyTrue when
valid / invalidValidation passes / fails
pristine / dirtyUnchanged / changed by the user
untouched / touchedNever blurred / has been blurred
pendingAn async validator is running
errorsAn 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-invalid tells a screen reader the field is in error.
  • aria-describedby links 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-drivenReactive
Structure defined inThe templateThe class
ValidationDirectivesFunctions
Dynamic fieldsAwkwardStraightforward
Unit testingNeeds the DOMPlain object
Value changesHard to observevalueChanges observable
Type safetyWeakStrong with typed forms
Best forShort, static formsEverything 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

MessageCauseFix
Can't bind to 'ngModel'FormsModule not importedImport it
Validation messages show immediatelyNot checking touched or dirtyGuard on them
The form submits while invalidNo check on form.validCheck it, and disable the button
ngModel needs a name attributeMissing name inside a formAdd it
The server still rejects a valid-looking formClient validation is not the controlValidate 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 name on an ngModel control 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 disabled on submit, allowing a double-click
  • [value] instead of [ngValue] for an object option
  • No empty first <option>, so required on a select does nothing
  • type="number" for a phone number
  • ng-invalid styled without ng-touched
  • Missing multi: true on 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.

  1. Build the student form with ngModel, required, pattern and maxlength on every relevant field.
  2. Remove name from one control. Record the runtime error.
  3. Remove novalidate and submit an invalid form. Compare the browser's message with Angular's.
  4. Show errors without checking touched. Load the page and observe every field red.
  5. Add touched || submitted and confirm the improvement.
  6. Click Save on an untouched invalid form without markAllAsTouched(). Confirm nothing appears, then add it.
  7. Add aria-invalid, aria-describedby and role="alert". Test with a screen reader.
  8. Remove type="button" from Cancel and click it.
  9. Remove [disabled]="saving()" and double-click Save. Confirm two students.
  10. Build a select with an object option using [value], then [ngValue]. Compare.
  11. Remove the empty first <option> and confirm required no longer fires.
  12. Write MinAgeDirective with multi: true. Remove multi and observe every other validator stop working.
  13. Write the async unique-roll-number validator with debounce and cancellation. Show the pending state.
  14. In DevTools, delete required from an input and submit. Confirm the server still rejects it.
  15. Add server-error mapping from a 400 errors object 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

  1. Why is name mandatory on an ngModel control inside a form?
  2. Why check touched || submitted before showing an error?
  3. What does multi: true do on a custom validator provider?
  4. When are reactive forms the better choice?

Next: Reactive forms