Difference between template-driven and reactive forms in Angular
Template-driven and reactive forms - two approaches Angular provides for building forms, each requiring different imports, a different mental model, and a different tradeoff between speed of setup and depth of control.
Theory
TL;DR
- Template-driven: import
FormsModule, addngModelto inputs, Angular builds the form model from the template - Reactive: import
ReactiveFormsModule, defineFormGroupin TypeScript, bind withformControlName - Main difference: template-driven uses two-way binding through
ngModel; reactive uses unidirectional flow via thevalueChangesobservable - Fewer than 5 static fields with no dynamic logic? Template-driven works. Dynamic fields, cross-field validators, server errors? Reactive.
- Reactive forms test without a DOM: create
FormGroupdirectly, callform.setValue(), checkform.valid
Quick example
Template-driven form:
<form #userForm="ngForm" (ngSubmit)="onSubmit(userForm)">
<input name="email" ngModel required email #email="ngModel">
<div *ngIf="email.invalid && email.touched">Invalid email</div>
<button [disabled]="userForm.invalid">Submit</button>
</form>onSubmit(form: NgForm) {
console.log(form.value); // { email: "test@example.com" }
}Angular reads the ngModel directive and auto-creates the form model from the template. The HTML is in charge here.
Reactive form:
form = new FormGroup({
email: new FormControl('', [Validators.required, Validators.email])
});
onSubmit() {
console.log(this.form.value); // { email: "test@example.com" }
}<form [formGroup]="form" (ngSubmit)="onSubmit()">
<input formControlName="email">
<div *ngIf="form.get('email')?.invalid && form.get('email')?.touched">
Invalid email
</div>
<button [disabled]="form.invalid">Submit</button>
</form>TypeScript defines the model first. The template just binds to it.
Key difference
Template-driven forms use directives like ngModel and NgForm to automatically generate and sync a form model from your HTML. You never create FormControl objects yourself. Reactive forms start with an explicit FormGroup built in TypeScript. That group is a programmable tree of controls you manipulate directly, observe through RxJS, and test without any DOM. State flows one way: from model to view.
When to use
- Login or contact form with 3-4 static fields: template-driven, faster to write
- Fields that appear or disappear based on user input: reactive (
addControl()/removeControl()) - Cross-field validation (password must match confirm-password): reactive, group-level validator function
- Server-side error mapped to a specific field: reactive (
control.setErrors()) - Form logic that needs unit tests without a browser: reactive, pure TypeScript
- Quick prototype or a simple Ionic screen: template-driven
In my experience, most teams end up switching to reactive after the first time they need to dynamically add a field to a template-driven form and realize there is no API for it.
Comparison table
| Characteristic | Template-driven | Reactive |
|---|---|---|
| Setup | FormsModule + ngModel in HTML | ReactiveFormsModule + FormGroup in TS |
| Data flow | Two-way binding (ngModel) | Unidirectional (valueChanges observable) |
| Validation | HTML attributes + directive errors | Validators array on FormControl |
| Dynamic fields | Workarounds with *ngFor | addControl() / removeControl() |
| Async validators | Possible, more setup | Built-in via asyncValidators option |
| Testing | Requires DOM, harder to isolate | Unit-test FormGroup directly |
| When to use | Simple, static forms | Dynamic, complex, or tested forms |
How Angular handles both internally
For template-driven forms, Angular's compiler scans the template for ngModel directives and builds a NgForm model by matching name attributes to a directive tree. Miss the name attribute on an input and that field simply won't appear in form.value.
Reactive forms construct the FormGroup tree in TypeScript at runtime. Value changes emit through valueChanges and statusChanges observables without any template involvement. That is why tests can call form.setValue() and read form.valid instantly, with no component rendering needed.
Common mistakes
1. Mixing ngModel with reactive forms
<!-- Causes NG0100 error or auto-override of the reactive model -->
<form [formGroup]="form">
<input formControlName="email" [(ngModel)]="model.email">
</form>Pick one approach. Remove ngModel when using formControlName.
2. Mutating form.value directly
// Wrong: Angular does not detect this, UI won't update
this.form.value.email = 'new@email.com';
// Correct
this.form.patchValue({ email: 'new@email.com' });form.value is a snapshot. It is not a writable model.
3. Forgetting name on template-driven inputs
<!-- This field won't appear in form.value -->
<input ngModel required>
<!-- Correct -->
<input name="email" ngModel required>4. Not subscribing to valueChanges
// Does nothing
this.form.valueChanges;
// Correct
this.form.valueChanges
.pipe(takeUntil(this.destroy$))
.subscribe(value => console.log(value));valueChanges is an observable. Reading it without subscribing has no effect.
5. Expecting async validators to run after patchValue
// Won't trigger the async validator if updateOn: 'blur' is set
this.emailControl.patchValue('new@example.com');
// Force revalidation manually
this.emailControl.updateValueAndValidity();Angular recommends updateOn: 'blur' for async validators to avoid per-keystroke API calls. But patchValue won't trigger that validator automatically.
Real-world usage
- Angular Material
mat-stepperwizards: reactive, because cross-step validation is needed - NgRx store integration: reactive
FormGroupvalues stored in state for undo/redo - JSON-driven dynamic forms (e.g.,
jsonforms.io): reactive withFormArrayfor variable-length lists - Ionic login screens and simple contact forms: template-driven, less setup
Follow-up questions
Q: How do you add a new field to a form after it has already been rendered?
A: Reactive only: this.form.addControl('phone', new FormControl('')). Template-driven has no API for this. You can work around it with *ngFor over an array of field configs, but that approach gets messy quickly.
Q: What is the difference between setValue and patchValue?
A: setValue requires every field in the FormGroup or it throws an error. patchValue accepts a partial object and only updates the fields you include.
Q: How do you test a reactive form without a browser?
A: Create the FormGroup directly in the test, call form.setValue() or form.patchValue(), then check form.valid or specific control errors. No component rendering needed at all.
Q: How do you stop an async validator from firing on every keystroke?
A: Set updateOn: 'blur' in the FormControl options. The validator runs only when the field loses focus, not on every change event.
Q: A FormArray has 10 rows. The user reorders them. Async validators fire on the wrong rows after the swap. How do you fix it?
A: Track rows by a stable unique id, not by array index. Add debounceTime to the async validator to batch rapid changes. After a reorder, call updateValueAndValidity() explicitly on affected controls rather than relying on Angular to re-trigger it automatically. Referencing controls by index with AbstractControl.at(i) after the swap is also safer than holding stale references.
Examples
Login form (template-driven)
<form #loginForm="ngForm" (ngSubmit)="onSubmit(loginForm)">
<input name="email" ngModel required email #emailField="ngModel">
<div *ngIf="emailField.invalid && emailField.touched">Enter a valid email</div>
<input name="password" type="password" ngModel required minlength="8">
<button [disabled]="loginForm.invalid">Log in</button>
</form>onSubmit(form: NgForm) {
if (form.valid) {
console.log(form.value); // { email: "...", password: "..." }
this.authService.login(form.value);
}
}Angular builds the NgForm model from name attributes and ngModel directives. Validation rules come from HTML attributes. This covers the majority of simple form cases with minimal code.
Registration with cross-field validation (reactive)
import { FormBuilder, FormGroup, Validators, AbstractControl } from '@angular/forms';
export class RegisterComponent {
form: FormGroup;
constructor(private fb: FormBuilder) {
this.form = this.fb.group({
email: ['', [Validators.required, Validators.email]],
confirmEmail: ['', Validators.required],
password: ['', [Validators.required, Validators.minLength(8)]]
}, { validators: this.emailMatchValidator });
}
emailMatchValidator(group: AbstractControl) {
const email = group.get('email')?.value;
const confirm = group.get('confirmEmail')?.value;
return email === confirm ? null : { mismatch: true };
}
addPhoneField() {
this.form.addControl('phone', this.fb.control(''));
}
}<form [formGroup]="form" (ngSubmit)="onSubmit()">
<input formControlName="email">
<input formControlName="confirmEmail"
[class.error]="form.hasError('mismatch')">
<div *ngIf="form.hasError('mismatch')">Emails do not match</div>
<button type="button" (click)="addPhoneField()">Add phone</button>
<button [disabled]="form.invalid">Register</button>
</form>The group-level validator runs on the whole FormGroup and sets form.errors = { mismatch: true } when emails differ. addPhoneField() adds a new control at runtime. Neither of these things is possible with a template-driven form.
Async validator with server check
import { AbstractControl } from '@angular/forms';
import { timer, of } from 'rxjs';
import { switchMap, map, catchError } from 'rxjs/operators';
asyncEmailValidator(control: AbstractControl) {
return timer(500).pipe(
switchMap(() =>
this.http.get<boolean>(`/api/check-email/${control.value}`)
),
map(isTaken => isTaken ? { emailTaken: true } : null),
catchError(() => of(null)) // network error: do not block the form
);
}
// Usage
email = new FormControl('', {
validators: [Validators.required, Validators.email],
asyncValidators: [this.asyncEmailValidator.bind(this)],
updateOn: 'blur' // only checks when the user leaves the field
});timer(500) debounces the call so rapid typing doesn't fire multiple requests. updateOn: 'blur' limits the trigger to when the field loses focus. If the API is unavailable, catchError returns null so the form doesn't get stuck in an invalid state. One edge case: calling patchValue() on this control won't rerun the async validator. Call updateValueAndValidity() explicitly when you need to force it.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.