Template-driven forms use Angular directives in the template to create form controls and manage form state. You bind form inputs with [(ngModel)] and add validation with standard HTML attributes like required and minlength — Angular enhances these with reactive form state (valid, invalid, touched, dirty). The form’s overall validity is accessible via #form='ngForm'. Template-driven forms are the right choice for simple forms where the logic fits cleanly in the template without complex cross-field validation or programmatic control.
Template-Driven Form Setup
import { Component, signal, inject } from '@angular/core';
import { FormsModule, NgForm } from '@angular/forms';
import { Router } from '@angular/router';
import { AuthApiService } from '@core/services/auth-api.service';
@Component({
selector: 'app-login',
standalone: true,
imports: [FormsModule], // required for ngModel and NgForm
template: `
<!-- #loginForm='ngForm' exports the NgForm directive instance ──────── -->
<form #loginForm='ngForm' (ngSubmit)="onSubmit(loginForm)"
class="login-form" novalidate>
<!-- name attribute required for NgModel to register the control ───── -->
<div class="field">
<label for="email">Email</label>
<input id="email"
type="email"
name="email"
[(ngModel)]="credentials.email"
required
email
#emailControl="ngModel"> <!-- export ngModel for this field ─ -->
<!-- Show error only after user has touched the field ──────────── -->
@if (emailControl.invalid && emailControl.touched) {
<span class="error">
@if (emailControl.errors?.['required']) { Email is required. }
@if (emailControl.errors?.['email']) { Enter a valid email address. }
</span>
}
</div>
<div class="field">
<label for="password">Password</label>
<input id="password"
type="password"
name="password"
[(ngModel)]="credentials.password"
required
minlength="8"
#passwordControl="ngModel">
@if (passwordControl.invalid && passwordControl.touched) {
<span class="error">
@if (passwordControl.errors?.['required']) { Password is required. }
@if (passwordControl.errors?.['minlength']) {
Password must be at least
{{ passwordControl.errors?.['minlength'].requiredLength }} characters.
}
</span>
}
</div>
<!-- Server-level error (after API call) ─────────────────────────── -->
@if (serverError()) {
<div class="server-error">{{ serverError() }}</div>
}
<!-- Disable button while form invalid or submitting ─────────────── -->
<button type="submit"
[disabled]="loginForm.invalid || isSaving()">
{{ isSaving() ? 'Signing in...' : 'Sign In' }}
</button>
</form>
`,
})
export class LoginComponent {
private authApi = inject(AuthApiService);
private router = inject(Router);
// Two-way bound model — ngModel reads/writes here
credentials = { email: '', password: '' };
isSaving = signal(false);
serverError = signal('');
onSubmit(form: NgForm): void {
if (form.invalid) return; // extra safety check
this.isSaving.set(true);
this.serverError.set('');
this.authApi.login(this.credentials).subscribe({
next: () => this.router.navigate(['/posts']),
error: err => {
this.isSaving.set(false);
this.serverError.set(err.status === 401
? 'Invalid email or password.'
: 'Login failed. Please try again.');
},
});
}
}
name attribute is required on every form control that uses [(ngModel)] inside a <form> element. Without name, Angular cannot register the control with the parent NgForm — the control exists in isolation and the form’s validity does not include it. The name value becomes the key in form.value (e.g., form.value.email). You can also use [ngModelOptions]="{standalone: true}" to explicitly opt out of form registration when you want an ngModel that does not participate in the form.touched state rather than dirty to trigger error display. touched becomes true when the user focuses then leaves a field (blur event) — errors appear after the user has finished with the field. dirty becomes true on the first keystroke — errors would appear as soon as typing starts, before the user has had a chance to complete their input. The touched && invalid pattern gives the best user experience: errors appear precisely when the user has interacted and not provided valid input.novalidate to the <form> element to disable native browser validation. Without it, the browser shows its own native validation popups (tooltip bubbles) alongside Angular’s template validation messages, resulting in duplicate error displays. Angular’s validation directives handle all validation feedback — the browser’s native UI is redundant and inconsistent across browsers. Always use novalidate on Angular forms.Form State Reference
| State | CSS Class | Meaning |
|---|---|---|
| valid | ng-valid | All validators pass |
| invalid | ng-invalid | At least one validator fails |
| pristine | ng-pristine | Value unchanged since creation |
| dirty | ng-dirty | Value changed at least once |
| touched | ng-touched | Focus then blur at least once |
| untouched | ng-untouched | Never been blurred |
Common Mistakes
Mistake 1 — Missing name attribute on ngModel controls (form validity not tracked)
❌ Wrong — <input [(ngModel)]="email"> without name="email"; not registered in NgForm.
✅ Correct — always add name="fieldName" to every ngModel inside a form element.
Mistake 2 — Showing errors based on dirty instead of touched (errors appear while typing)
❌ Wrong — *ngIf="control.invalid && control.dirty"; error appears on the first keystroke.
✅ Correct — use control.invalid && control.touched; errors appear after the field loses focus.