Template-Driven Forms
Template-driven forms let you build a working, validated form almost entirely in the HTML template, with very little TypeScript. You attach a handful of directives — chiefly ngModel and ngForm — and Angular quietly assembles the underlying FormControl/FormGroup model for you behind the scenes. For small, mostly-static forms like a login box, a contact form, or a settings panel, this is the fastest, most readable approach Angular offers.
Getting started with FormsModule
Everything template-driven lives in FormsModule. Import it into your standalone component and you immediately get access to the ngModel, ngForm, and validation directives. Without it, [(ngModel)] will throw a template error.
import { Component } from '@angular/core';
import { FormsModule, NgForm } from '@angular/forms';
import { JsonPipe } from '@angular/common';
@Component({
selector: 'app-contact',
standalone: true,
imports: [FormsModule, JsonPipe],
template: `
<form #f="ngForm" (ngSubmit)="submit(f)">
<label>
Name
<input name="name" [(ngModel)]="model.name" required minlength="2" />
</label>
<label>
Email
<input name="email" type="email" [(ngModel)]="model.email" required email />
</label>
<button type="submit" [disabled]="f.invalid">Send</button>
</form>
<pre>{{ f.value | json }}</pre>
`,
})
export class ContactComponent {
model = { name: '', email: '' };
submit(form: NgForm): void {
if (form.valid) {
console.log('Submitting', form.value);
}
}
}
Output:
Submitting { name: 'Ada Lovelace', email: '[email protected]' }
The component class is nearly empty — the directives do the wiring. That is the core appeal of the template-driven style.
ngModel and two-way binding
ngModel is the heart of the approach. Wrapped in the banana-in-a-box syntax [(ngModel)], it creates a two-way binding so the input value and your component property stay in sync. Crucially, every input that participates in a form must have a name attribute — that name becomes the key in the generated form model.
You can also use ngModel in three other modes:
| Syntax | Direction | Use it when |
|---|---|---|
[(ngModel)]="prop" | Two-way | You want the value mirrored on a property |
[ngModel]="prop" | One-way in | You only push values into the control |
ngModel (standalone) | Registers control | You read state via the template reference only |
(ngModelChange)="fn($event)" | One-way out | You react to each change manually |
A common gotcha: forgetting the
nameattribute. Without it,ngModelcannot register the control with the parentngForm, and the value silently disappears fromform.value.
Tracking form state with ngForm
When you place a <form> element inside a component that imports FormsModule, Angular automatically attaches the ngForm directive. Exporting it with #f="ngForm" gives you a template reference to the whole form’s state. The same is true per-control: #name="ngModel" exposes one control’s status.
Both expose the same boolean status flags, which you use to drive your UI:
| Property | Meaning |
|---|---|
valid / invalid | Whether all validators pass |
pristine / dirty | Whether the value has changed |
touched / untouched | Whether the control has been blurred |
submitted | Whether the form has been submitted |
value | The aggregated { name: value } model object |
Built-in validation directives
Template-driven forms reuse standard HTML validation attributes, but Angular intercepts them and feeds the results into the form model. You get required, minlength, maxlength, pattern, and email out of the box. Combine a control reference with the new @if control flow to display messages only after the user interacts.
<form #f="ngForm" (ngSubmit)="submit(f)">
<input
name="username"
[(ngModel)]="model.username"
#username="ngModel"
required
minlength="3"
pattern="[a-z0-9]+"
/>
@if (username.invalid && (username.dirty || username.touched)) {
<div class="errors">
@if (username.errors?.['required']) {
<p>Username is required.</p>
}
@if (username.errors?.['minlength']) {
<p>At least 3 characters, please.</p>
}
@if (username.errors?.['pattern']) {
<p>Lowercase letters and digits only.</p>
}
</div>
}
<button type="submit" [disabled]="f.invalid">Save</button>
</form>
Each failed validator adds an entry to the control’s errors object, keyed by the validator name. Checking dirty || touched ensures you don’t scold the user before they’ve typed anything.
Grouping fields with ngModelGroup
For nested data, ngModelGroup creates a sub-FormGroup inside the form. This keeps related fields together and mirrors the shape of your backend payload.
<form #f="ngForm" (ngSubmit)="submit(f)">
<fieldset ngModelGroup="address">
<input name="street" ngModel required />
<input name="city" ngModel required />
<input name="zip" ngModel required pattern="\d{5}" />
</fieldset>
<button type="submit" [disabled]="f.invalid">Continue</button>
</form>
The resulting f.value becomes { address: { street, city, zip } }, and the group is invalid whenever any child control is invalid.
Best Practices
- Always add a unique
nameattribute to everyngModelinput — it is required for the control to register. - Gate error messages behind
dirty || touched(orf.submitted) so users aren’t warned on first paint. - Keep template-driven forms for small, static forms; reach for reactive forms once logic, typing, or dynamic fields are involved.
- Use
ngModelGroupto mirror nested payload structures instead of flattening everything onto one model. - Disable the submit button with
[disabled]="f.invalid", but always re-validate on the server — client validation is UX, not security. - Never combine
ngModelandformControlNameon the same control; mixing the two form strategies is explicitly discouraged.