Ch. 4 · Angular

Angular Template-Driven vs Reactive Forms

Both form styles build the same FormControl objects. What differs is who creates them and when, which changes timing, typing and testing.

~5 min readintermediateupdated Oct 19, 2026

“Template-driven or reactive?” is one of the most asked Angular form questions, and the weakest answer is a list of adjectives: template-driven is “simple”, reactive is “scalable”. Interviewers want to hear what actually differs underneath. Once you know that, the trade-offs follow on their own.

Here’s the fact that reframes the whole comparison: both styles end up with the same objects. A template-driven form is an NgForm directive holding a FormGroup, and each ngModel directive holds a FormControl. A reactive form is a FormGroup of FormControls you construct yourself. Validators, valid, touched, errors and the CSS classes like ng-invalid all come from the same classes. The difference is who creates the form model, when it’s created, and where the data lives.

The same form written both ways

A “change email” form with one field, required and a valid email.

Template-driven, with FormsModule imported:

@Component({
  selector: 'app-change-email',
  imports: [FormsModule],
  template: `
    <form #f="ngForm" (ngSubmit)="save(f)">
      <label for="email">New email</label>
      <input id="email" name="email" [(ngModel)]="email" required email />
      @if (f.controls['email']?.invalid && f.controls['email']?.touched) {
        <p>Enter a valid email address.</p>
      }
      <button [disabled]="f.invalid">Save</button>
    </form>
  `,
})
export class ChangeEmailTd {
  email = 'ada@example.com';
  save(f: NgForm) { /* call the API with this.email */ }
}
TypeScript

Reactive, with ReactiveFormsModule imported:

@Component({
  selector: 'app-change-email',
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="form" (ngSubmit)="save()">
      <label for="email">New email</label>
      <input id="email" formControlName="email" />
      @if (form.controls.email.invalid && form.controls.email.touched) {
        <p>Enter a valid email address.</p>
      }
      <button [disabled]="form.invalid">Save</button>
    </form>
  `,
})
export class ChangeEmailReactive {
  readonly form = new FormGroup({
    email: new FormControl('ada@example.com', {
      nonNullable: true,
      validators: [Validators.required, Validators.email],
    }),
  });
  save() { const { email } = this.form.getRawValue(); /* call the API */ }
}
TypeScript

In the template-driven version, validation is declared with attributes (required, email), which are themselves directives that add validators to the control ngModel creates. The data lives in a plain component field, email, and [(ngModel)] keeps it in sync. In the reactive version the template contains no rules at all. The FormGroup is the data, and you read it with getRawValue().

Notice also the name="email" attribute. Inside a <form>, ngModel uses name to register its control with the parent NgForm. Leave it off and Angular throws NG01352: If ngModel is used within a form tag, either the name attribute must be set or the form control must be defined as 'standalone' in ngModelOptions.

Timing: the difference you can measure

Because ngModel directives create and register their controls, the form model in a template-driven form doesn’t exist until the template has been processed, and Angular defers that registration to a microtask. Running both components above in an Angular 20 TestBed and logging at each stage gives:

TD ngAfterViewInit controls: [] value: {}
TD right after detectChanges: []
TD after whenStable: {"email":"ada@example.com"} valid true
TD after typing: grace valid false {"email":true}
TD model set, form value same tick: {"email":"grace"}
TD model set, after whenStable: {"email":"x@y.z"}
RF constructor value: {"email":"ada@example.com"} valid true
RF after typing: {"email":"grace"} {"email":true}
Text

Three consequences come straight out of that log.

In ngAfterViewInit, a template-driven form has no controls. Code that tries this.f.controls['email'].setValue(...) or this.f.form.patchValue(...) there fails or does nothing. People hit this when restoring a draft on load.

When you change the bound field in code (this.email = 'x@y.z'), the form’s value lags by a tick. A validity check on the very next line still sees the old value.

The reactive form is fully built and validated in the constructor, before any template exists. You can patchValue, subscribe to valueChanges or check valid anywhere, synchronously.

Typing works the same way in both: one input event updates the value and runs validators immediately. The asynchrony is about the model being created and written from code, not about user input.

Dynamic fields and cross-field rules

Template-driven forms mirror the template. A field exists because an element with ngModel exists, so adding fields means adding elements, typically with @for. That’s workable for a short list, but anything that reasons about the collection (minimum three items, no duplicate emails, reorder) has nowhere natural to live.

Reactive forms have FormArray for exactly this: items.push(new FormControl('')), items.removeAt(i), a validator on the array itself. Cross-field rules, such as check-out after check-in, are a validator function on the parent FormGroup. In template-driven forms the equivalent needs a custom directive with NG_VALIDATORS on the ngModelGroup or the form, which is more code and harder to find.

Types and tests

Since Angular 14, reactive forms are strongly typed. form.controls.email.value is a string, form.getRawValue() returns { email: string }, and a typo like form.controls.emial fails to compile. The template-driven side is typed only through your component field; f.value is any, and f.controls['email'] is looked up by string.

Testing follows the same split. A reactive form’s logic can be tested with no TestBed at all: create the component class or just the FormGroup, set values, assert errors. Testing a template-driven form means rendering the template, awaiting fixture.whenStable() so the controls register, then dispatching input events. Neither is hard, but the reactive tests are faster and less fragile.

Choosing, and where Signal Forms fit

Template-driven forms are a reasonable choice for small, static forms whose data is a plain object you already have: a login box, a search filter, a settings toggle panel. Less code, and the template reads like the form.

Reactive forms fit when the form is dynamic (fields added at runtime), has rules across fields, is loaded and patched from an API, or needs thorough unit tests. In most enterprise codebases that’s the majority, which is why many teams standardise on reactive forms everywhere for consistency.

On Angular 21 and later there’s a third option, Signal Forms, which keeps the data in a signal you own and declares rules in a schema. It’s worth mentioning if the interviewer’s team is on a recent version, but don’t present it as a reason to rewrite existing reactive forms.

Interview exercise: restoring a draft

“A long template-driven application form should restore a saved draft from localStorage on load. The developer calls this.form.setValue(draft) in ngAfterViewInit and gets NG01000: There are no form controls registered with this group yet. If you're using ngModel, you may want to check next tick. Why, and how would you fix it?”

Answer. In a template-driven form the controls are created by the ngModel directives and registered with NgForm asynchronously, after the view has been checked. In ngAfterViewInit the group is still empty, so setValue has nothing to set. The simplest fix is not to touch the form API at all: assign the draft to the component fields that [(ngModel)] is bound to, for example this.application = { ...this.application, ...draft }, ideally in the constructor or ngOnInit, and the directives will pick the values up when they register. If the code genuinely needs the form API (to mark fields touched, say), wait a tick for registration (the error message itself suggests setTimeout) or, in a test, await fixture.whenStable(). If the form keeps growing and needs patching, validation across sections and unit tests, that’s a sign it should be a reactive form, where the FormGroup exists in the constructor and patchValue(draft) works immediately.

Further reading

More in Angular

read ✓Angular · hard

Angular Signal Forms Overview

How Signal Forms wrap your model signal in a FieldTree, keep validation in one schema, and map server errors onto fields when you submit.

~6 min readread →
esc