Angular Intermediate #2: Template-driven Forms and a Comparison
In #1 we covered one of Angular’s two form approaches — Reactive Forms. You build FormGroup and FormControl directly in the component class, and the template just connects to those objects. But Angular has another form approach. That’s Template-driven Forms.
As the name suggests, in this approach the entire form’s logic lives on the template (HTML). You don’t declare a separate form object in the component class; instead, a directive called ngModel does almost all the work. In this post, we walk through how to use Template-driven Forms, then put the two approaches side by side at the end.
What is Template-driven? #
Template-driven Forms are exactly that — the template drives the form. The structure, the validation rules, the state tracking — Angular infers all of it from directives written inside the HTML.
The key is a directive called ngModel. We actually met it briefly in Basics #3 when we covered two-way binding. Back then we used it as a simple way to bind a variable to an input — [(ngModel)]="name" — but ngModel was doing much more than that. When used inside a form, it automatically registers that input as a control on the form and tracks its value and validation state (valid/invalid, touched/dirty, etc.) the whole time.
Setting up FormsModule #
To use Template-driven Forms, import FormsModule. In a standalone component, add it directly to the component’s imports array.
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-signup-form',
standalone: true,
imports: [FormsModule],
templateUrl: './signup-form.component.html',
})
export class SignupFormComponent {
// Directives like ngModel and ngForm are now usable in the template
}For Reactive Forms we imported ReactiveFormsModule. For Template-driven, you swap that out for FormsModule. Importing both at the same time doesn’t conflict, but in a single component it’s cleaner to commit to one approach.
Two-way binding with [(ngModel)]
#
Let’s start with the simplest example.
<input
type="text"
name="username"
[(ngModel)]="username"
placeholder="Username"
/>
<p>You typed: {{ username }}</p>export class SignupFormComponent {
username = '';
}There’s one important rule here. When using ngModel inside a form, you must add a name attribute. Angular uses that name value as the key to register the control inside the form object. Without name, you’ll see a console error and the form won’t work properly.
[(ngModel)] purely to bind an input value to a variable outside a form, the name attribute isn’t required. But the moment it’s inside a <form> tag, name becomes mandatory. Think of it as a rule: inside a form, name is always required.ngForm and (ngSubmit)
#
Now let’s handle the whole form. When you use a <form> tag, Angular automatically attaches an NgForm instance there. To access that object, grab a reference to it with template variable syntax (#).
<form #userForm="ngForm" (ngSubmit)="onSubmit(userForm)">
<label>
Username
<input type="text" name="username" [(ngModel)]="model.username" />
</label>
<label>
Email
<input type="email" name="email" [(ngModel)]="model.email" />
</label>
<button type="submit">Sign up</button>
</form>
<p>Form valid? {{ userForm.valid }}</p>import { Component } from '@angular/core';
import { FormsModule, NgForm } from '@angular/forms';
@Component({
selector: 'app-signup-form',
standalone: true,
imports: [FormsModule],
templateUrl: './signup-form.component.html',
})
export class SignupFormComponent {
model = { username: '', email: '' };
onSubmit(form: NgForm) {
if (form.valid) {
console.log('submitted:', form.value);
}
}
}#userForm="ngForm" says: “grab the ngForm directive instance attached to this form, and call it userForm.” Once you have that handle, you can work with the entire form object both inside the template and inside the component class via (ngSubmit).
The reason for (ngSubmit) instead of (submit) is that (ngSubmit) automatically prevents the browser’s default page-reload behavior and emits the submit event with the form already validated. For Template-driven forms, you’ll almost always use (ngSubmit).
Validation — through HTML5 attributes #
The biggest selling point of Template-driven Forms is that validation is written as HTML5 attributes. Standard attributes like required, minlength, maxlength, pattern, email are picked up by Angular as validation rules.
<form #userForm="ngForm" (ngSubmit)="onSubmit(userForm)">
<input
type="text"
name="username"
[(ngModel)]="model.username"
required
minlength="3"
/>
<input
type="email"
name="email"
[(ngModel)]="model.email"
required
email
/>
<input
type="password"
name="password"
[(ngModel)]="model.password"
required
pattern="^(?=.*[A-Za-z])(?=.*\d).{8,}$"
/>
<button type="submit" [disabled]="userForm.invalid">Sign up</button>
</form>The same validation in Reactive Forms would mean writing Validators.required, Validators.minLength(3), Validators.email, Validators.pattern(...) as arrays in the component class. In Template-driven, all of that fits in HTML attributes. For short forms, it really is convenient.
[disabled]="userForm.invalid" is also worth noting. While the form as a whole is invalid, this auto-disables the submit button. No separate boolean variable, no helper method needed.
Form state and error display #
Each control on the form carries the following state flags.
valid/invalid— whether validation passespristine/dirty— whether the user has changed the value at least onceuntouched/touched— whether the user has focused and then blurred the field at least once
Use these flags to build the natural UX of “show errors only after the user has left the field.”
<form #userForm="ngForm" (ngSubmit)="onSubmit(userForm)">
<label>
Email
<input
type="email"
name="email"
[(ngModel)]="model.email"
#emailCtrl="ngModel"
required
email
/>
</label>
@if (emailCtrl.invalid && emailCtrl.touched) {
<p class="error">
@if (emailCtrl.errors?.['required']) {
Please enter your email.
}
@if (emailCtrl.errors?.['email']) {
That doesn't look like a valid email.
}
</p>
}
<button type="submit" [disabled]="userForm.invalid">Sign up</button>
</form>#emailCtrl="ngModel" is the pattern for grabbing a directive instance not for the whole form but for an individual control. With that handle you can directly access that one control’s valid, touched, errors, etc.
If you want to access it from the whole-form side, you can also reach in with userForm.controls['email']?.errors. Once you have many controls, the latter sometimes reads cleaner.
Reactive vs Template-driven #
Now let’s place the two approaches side by side.
| Aspect | Template-driven | Reactive |
|---|---|---|
| Where the form is defined | HTML template | Component class |
| Core tools | ngModel, ngForm, HTML5 attributes | FormGroup, FormControl, Validators |
| Module to import | FormsModule | ReactiveFormsModule |
| Learning curve | Low — HTML-friendly | Medium — comfortable with objects and Observables |
| Dynamic forms (add/remove fields) | Awkward | Very natural (FormArray) |
| Unit testing | Heavier — needs a template | Just the class is enough — lightweight |
| Async validation | Possible but awkward | Integrates naturally |
| Subscribing to value changes | Awkward | Clean via valueChanges Observable |
A rough summary:
- Template-driven: when the form is simple, HTML5-level validation is enough, and you’d like “the whole form to fit on one screen of HTML.”
- Reactive: when the form is complex, when fields grow and shrink based on input, when other parts of the page need to react to value changes, or when unit testing matters.
Practical recommendation — which one should you pick? #
For a brand-new project, the general advice is default to Reactive Forms. The code looks slightly heavier at first, but as the form grows the value of that structure shows up. Validation rules live together in one place inside the class, so they’re easy to track; adding a new field has a clear, small set of edits; and unit tests can rest cleanly on the component class alone.
That doesn’t mean Template-driven is outdated or pointless. For forms with one or two inputs and lightweight validation — header search boxes, newsletter sign-ups, comment forms — Reactive Forms boilerplate is overkill. For those cases, finishing the job in a few HTML lines with Template-driven is much more natural.
Also, in environments where you collaborate with designers or markup-heavy contributors, the fact that they can directly tweak validation attributes in HTML is a real advantage. Keep in mind that the right answer can shift depending on team composition and project nature.
Wrapping up #
In this post we covered how Template-driven Forms work and compared them to Reactive Forms. To summarize:
- Template-driven builds the form template-first.
ngModeland HTML5 attributes are the core. - Inside a form,
ngModelrequires anameattribute. - Use
#userForm="ngForm"for the whole form,#ctrl="ngModel"for an individual control. - Simple, lightweight forms → Template-driven; complex, dynamic, test-heavy forms → Reactive.
- The default for new projects is Reactive first; reach for Template-driven only for genuinely simple forms.
In the next post, “Angular Intermediate #3 RxJS Basics,” we cover Angular’s other big pillar — RxJS. What Observable is, why Angular brought it in for async flows like HTTP responses and router parameters, and the most-used operators you should have in muscle memory.