Forms in Angular
Template-driven vs reactive forms, and a complete reactive form example with validation.
Two form models, deliberately kept separate
Angular ships two entirely distinct APIs for building forms, and — unlike most of the framework, where there's one clear modern default — both remain fully supported and genuinely useful for different situations. Understanding which is which, and why they're organized as separate modules at all, matters before writing any real form code.
| Template-driven forms | Reactive forms | |
|---|---|---|
| Form model lives in | The template (ngModel directives) |
The component class (FormGroup/FormControl objects) |
| Import needed | FormsModule |
ReactiveFormsModule |
| Validation | Template-attribute-based (required, minlength) |
Defined programmatically alongside the form model |
| Testability | Harder — logic is embedded in the template | Easier — the form model is a plain object, testable without rendering |
| Best fit | Small, simple forms (a search box, a single field) | Anything with real validation, dynamic fields, or complex logic |
Template-driven forms
A template-driven form's state lives mostly in the template itself, bound with ngModel — the two-way binding directive that mirrors v-model in Vue or bind:value in Svelte:
import { Component } from "@angular/core";
import { FormsModule } from "@angular/forms";
@Component({
selector: "app-search",
standalone: true,
imports: [FormsModule],
template: `
<input [(ngModel)]="searchTerm" placeholder="Search..." />
<p>Searching for: {{ searchTerm }}</p>
`,
})
export class SearchComponent {
searchTerm = "";
}
[(ngModel)] is Angular's "banana in a box" syntax — combining property binding ([ngModel], data going in) and event binding ((ngModelChange), an update coming out) into one two-way binding directive, requiring FormsModule to be imported. This is a reasonable, low-ceremony choice for something as simple as a single search field — it becomes unwieldy quickly for anything with cross-field validation or fields that need to be added/removed dynamically, which is exactly where reactive forms take over.
Reactive forms: building the model in the component
A reactive form's model — every field, its current value, and its validators — is built explicitly in the component class as a FormGroup of FormControls, with the template simply binding to that already-existing model rather than defining it:
import { Component } from "@angular/core";
import { ReactiveFormsModule, FormGroup, FormControl, Validators } from "@angular/forms";
@Component({
selector: "app-signup",
standalone: true,
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="signupForm" (ngSubmit)="onSubmit()">
<input formControlName="email" placeholder="Email" />
<input formControlName="password" type="password" placeholder="Password" />
<button type="submit" [disabled]="signupForm.invalid">Sign up</button>
</form>
`,
})
export class SignupComponent {
signupForm = new FormGroup({
email: new FormControl("", [Validators.required, Validators.email]),
password: new FormControl("", [Validators.required, Validators.minLength(8)]),
});
onSubmit() {
if (this.signupForm.valid) {
console.log(this.signupForm.value); // { email: "...", password: "..." }
}
}
}
[formGroup]="signupForm" binds the whole form model to the <form> element; formControlName="email" links one specific input to one FormControl inside that group. Validators.required, .email, and .minLength(8) are Angular's built-in validators, passed as an array to each FormControl's constructor — the form's overall .valid/.invalid state is automatically derived from every control's individual validity, which is exactly what [disabled]="signupForm.invalid" reads to keep the submit button disabled until the whole form passes.
A complete example: a reactive form with validation feedback
A real form typically needs to show which field is invalid and why, not just block submission silently:
import { Component } from "@angular/core";
import { ReactiveFormsModule, FormGroup, FormControl, Validators } from "@angular/forms";
@Component({
selector: "app-signup",
standalone: true,
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="signupForm" (ngSubmit)="onSubmit()">
<div>
<input formControlName="email" placeholder="Email" />
@if (email?.invalid && email?.touched) {
<p class="error">
@if (email?.errors?.['required']) { Email is required. }
@if (email?.errors?.['email']) { Enter a valid email address. }
</p>
}
</div>
<div>
<input formControlName="password" type="password" placeholder="Password" />
@if (password?.invalid && password?.touched) {
<p class="error">Password must be at least 8 characters.</p>
}
</div>
<button type="submit" [disabled]="signupForm.invalid">Sign up</button>
</form>
`,
})
export class SignupComponent {
signupForm = new FormGroup({
email: new FormControl("", [Validators.required, Validators.email]),
password: new FormControl("", [Validators.required, Validators.minLength(8)]),
});
get email() {
return this.signupForm.get("email");
}
get password() {
return this.signupForm.get("password");
}
onSubmit() {
if (this.signupForm.invalid) {
this.signupForm.markAllAsTouched(); // surface every field's error, even untouched ones
return;
}
console.log("Submitting:", this.signupForm.value);
}
}
.touched is what keeps an error message from appearing the instant the page loads, before the visitor has even interacted with the field — it only becomes true once the control has been focused and blurred at least once. .errors is an object keyed by whichever validator(s) currently fail ({ required: true }, { email: true }, and so on), which is what makes it possible to show a specific, meaningful message per validation rule rather than one generic "invalid" state. markAllAsTouched() is the standard way to reveal every field's validation errors at once when a visitor tries to submit an already-invalid form without having touched every field individually first.
Common mistakes
- Mixing
FormsModule(template-driven) andReactiveFormsModule(reactive) directives on the same form —[(ngModel)]alongsideformControlNameon one form is a common source of confusing, hard-to-debug behavior; pick one model per form. - Checking only
.invalidwithout also checking.touched(or.dirty) before showing an error message — without it, every field shows as invalid the instant the page loads, before the visitor has typed anything at all. - Forgetting
[disabled]="form.invalid"(or an equivalent check inside the submit handler) — without either, an invalid form can still be submitted, and validation becomes purely cosmetic. - Reaching for reactive forms' full
FormGroup/FormControlceremony for a single, trivial input (a lone search box) where a simple[(ngModel)]binding would be simpler and perfectly adequate.