Skip to main content

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, add ngModel to inputs, Angular builds the form model from the template
  • Reactive: import ReactiveFormsModule, define FormGroup in TypeScript, bind with formControlName
  • Main difference: template-driven uses two-way binding through ngModel; reactive uses unidirectional flow via the valueChanges observable
  • 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 FormGroup directly, call form.setValue(), check form.valid

Quick example

Template-driven form:

html
<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>
typescript
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:

typescript
form = new FormGroup({ email: new FormControl('', [Validators.required, Validators.email]) }); onSubmit() { console.log(this.form.value); // { email: "test@example.com" } }
html
<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

CharacteristicTemplate-drivenReactive
SetupFormsModule + ngModel in HTMLReactiveFormsModule + FormGroup in TS
Data flowTwo-way binding (ngModel)Unidirectional (valueChanges observable)
ValidationHTML attributes + directive errorsValidators array on FormControl
Dynamic fieldsWorkarounds with *ngForaddControl() / removeControl()
Async validatorsPossible, more setupBuilt-in via asyncValidators option
TestingRequires DOM, harder to isolateUnit-test FormGroup directly
When to useSimple, static formsDynamic, 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

html
<!-- 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

typescript
// 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

html
<!-- This field won't appear in form.value --> <input ngModel required> <!-- Correct --> <input name="email" ngModel required>

4. Not subscribing to valueChanges

typescript
// 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

typescript
// 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-stepper wizards: reactive, because cross-step validation is needed
  • NgRx store integration: reactive FormGroup values stored in state for undo/redo
  • JSON-driven dynamic forms (e.g., jsonforms.io): reactive with FormArray for 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)

html
<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>
typescript
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)

typescript
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('')); } }
html
<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

typescript
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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?