Forms Overview
Forms are the backbone of almost every real application — logins, checkout flows, settings panels, search bars. Angular ships two complementary strategies for handling user input: template-driven forms and reactive forms. Both build on the same underlying form model (FormControl, FormGroup, FormArray) but expose it very differently. Understanding the trade-offs up front saves you from painful rewrites later.
The two approaches at a glance
Template-driven forms keep most of the logic in the template. You sprinkle directives like ngModel onto inputs and Angular implicitly builds the form model behind the scenes. They read naturally and require very little component code, which makes them ideal for small, mostly-static forms.
Reactive forms flip the relationship: you define the form model explicitly in TypeScript and bind the template to it. The model is the source of truth, it is fully synchronous, and it is trivial to unit test without rendering a DOM. This explicitness scales far better as forms grow in complexity.
| Aspect | Template-driven | Reactive |
|---|---|---|
| Form model | Created implicitly by directives | Created explicitly in the class |
| Source of truth | The template | The component |
| Data flow | Asynchronous | Synchronous |
| Validation | Directive-based (required, minlength) | Functions (Validators.required) |
| Type safety | Limited | Strong with typed forms |
| Testability | Needs DOM rendering | Pure unit tests |
| Scales to complex/dynamic forms | Poorly | Excellently |
Both approaches are fully supported and production-ready. This is not “old vs new” — it is “simple vs scalable.” Pick per-form, not per-project.
A template-driven example
Import FormsModule into your standalone component, then drive everything from the template with [(ngModel)].
import { Component } from '@angular/core';
import { FormsModule, NgForm } from '@angular/forms';
import { JsonPipe } from '@angular/common';
@Component({
selector: 'app-signup',
standalone: true,
imports: [FormsModule, JsonPipe],
template: `
<form #f="ngForm" (ngSubmit)="submit(f)">
<input name="email" [(ngModel)]="model.email" required email />
<input name="password" [(ngModel)]="model.password" required minlength="8" />
@if (f.submitted && f.invalid) {
<p class="error">Please fix the highlighted fields.</p>
}
<button type="submit" [disabled]="f.invalid">Sign up</button>
</form>
<pre>{{ f.value | json }}</pre>
`,
})
export class SignupComponent {
model = { email: '', password: '' };
submit(form: NgForm): void {
if (form.valid) {
console.log('Submitting', form.value);
}
}
}
Output:
Submitting { email: '[email protected]', password: 'lovelace42' }
Notice there is almost no logic in the class — the directives wire up validation and value tracking automatically. The price is that the form’s shape lives in the template, so it is harder to reason about and test in isolation.
A reactive example
Reactive forms move the definition into the component. Inject FormBuilder with inject() and import ReactiveFormsModule.
import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
@Component({
selector: 'app-login',
standalone: true,
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form" (ngSubmit)="submit()">
<input formControlName="email" />
<input formControlName="password" type="password" />
@if (form.controls.email.invalid && form.controls.email.touched) {
<p class="error">A valid email is required.</p>
}
<button type="submit" [disabled]="form.invalid">Log in</button>
</form>
`,
})
export class LoginComponent {
private fb = inject(FormBuilder);
form = this.fb.nonNullable.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
});
submit(): void {
if (this.form.valid) {
console.log('Submitting', this.form.getRawValue());
}
}
}
Output:
Submitting { email: '[email protected]', password: 'hopper99' }
Because the model is declared in TypeScript, form.getRawValue() is fully typed, validation is composed from plain functions, and you can subscribe to form.valueChanges for reactive behaviour like autosave or dependent fields.
How to choose
Reach for template-driven when:
- The form is small and static (a contact form, a single search box).
- You want minimal boilerplate and the team is comfortable with
ngModel. - You don’t need to react to value changes programmatically.
Reach for reactive when:
- The form is large, dynamic, or built from data at runtime.
- You need strong typing, custom or async validators, or cross-field rules.
- You want to unit-test the form logic without a DOM.
- You require fine-grained control over value/status streams.
Avoid mixing
ngModelandformControlNameon the same control. Combining the two strategies leads to confusing behaviour and is explicitly discouraged.
Best Practices
- Default to reactive forms for anything beyond a trivial input — the explicit model pays off as requirements grow.
- Use
fb.nonNullable.group(...)(or typedFormControls) soreset()returns initial values instead ofnull. - Keep validation logic in reusable validator functions rather than scattering rules across templates.
- Show errors only after a control is
touchedor the form issubmittedto avoid yelling at users on first paint. - Always import the correct module per component:
FormsModulefor template-driven,ReactiveFormsModulefor reactive. - Disable the submit button with
[disabled]="form.invalid"but still re-validate on the server — client validation is UX, not security.