Tutorials

Angular Form Examples: Template Driven vs Reactive Forms

Key Takeaways

  • Template-driven forms declare bindings and validation mainly in the template; reactive forms define an explicit control model in TypeScript.
  • Use template-driven forms for small, stable forms. Prefer reactive forms when typed values, changing controls, reusable validation, or model-level tests justify the extra setup.
  • Both approaches support validation and can coexist in one application. Choose per form, not necessarily per application.
  • A TypeScript property type doesn't validate user input. Use appropriate input types, validators, and field-specific error states.

For Angular template-driven vs reactive forms, the central difference is where you declare and manage the form model. Template-driven forms let directives build the controls from your template; reactive forms let you construct those controls explicitly in the component. Both can implement the same user experience, but they put the maintenance and testing work in different places.

Angular Form Example: One Form, Two Implementations

We'll implement the same name-and-age form twice. Both versions require a name and an age expressed as a whole number from 0 through 120, show field-level errors after blur, preview current values, and create a submitted snapshot.

Submission here is local: it displays the accepted values rather than pretending to save them to a server.

Standalone Setup and Module Imports

The reference baseline is Angular 22 with standalone components. Run the examples in your workspace before relying on them in production. Check Angular's release and support information when selecting your project's baseline.

Use an existing browser-based Angular workspace. Save either implementation below as src/app/app.component.ts and src/app/app.component.html; they are alternatives, not files to combine. Keep <app-root></app-root> in src/index.html.

This minimal src/main.ts boots either implementation using bootstrapApplication.

import {bootstrapApplication} from '@angular/platform-browser';
import {AppComponent} from './app/app.component';

bootstrapApplication(AppComponent).catch((error: unknown) => {
  console.error('Application bootstrap failed', error);
});

Each standalone component imports its own template dependencies: FormsModule for template-driven forms or ReactiveFormsModule for reactive forms. In an existing NgModule-based component, put the appropriate forms module in the declaring NgModule's imports instead; NgModule applications haven't stopped working.

The Data Model

Save this as src/app/user.ts. Keep the accepted entity separate from the editable draft: an empty age field needs a representation, and null makes that explicit.

export class User {
  constructor(
    public name: string,
    public age: number,
  ) {}
}

export interface UserDraft {
  name: string;
  age: number | null;
}

Angular's number-input value accessor converts entered values to numbers and maps an empty input to null. A text input doesn't provide that numeric conversion just because your property is declared as number.

The Template-Driven Way

Here, [(ngModel)] synchronizes each input with the draft. Inside an Angular-managed form, a participating ngModel control needs a name so Angular can register it with NgForm.

app.component.ts

import {Component} from '@angular/core';
import {FormsModule, NgForm} from '@angular/forms';
import {User} from './user';
import type {UserDraft} from './user';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [FormsModule],
  templateUrl: './app.component.html',
})
export class AppComponent {
  user: UserDraft = {name: '', age: null};
  submitted: User | null = null;

  submitForm(form: NgForm): void {
    if (!form.valid || this.user.age === null) {
      form.form.markAllAsTouched();
      return;
    }
    this.submitted = new User(this.user.name, this.user.age);
  }

  resetForm(form: NgForm): void {
    this.user = {name: '', age: null};
    form.resetForm(this.user);
    this.submitted = null;
  }
}

app.component.html

<form #myForm="ngForm" (ngSubmit)="submitForm(myForm)" novalidate>
  <label for="td-name">Name</label>
  <input id="td-name" type="text" name="name" required
    [(ngModel)]="user.name" #nameField="ngModel"
    [attr.aria-invalid]="nameField.invalid && nameField.touched"
    [attr.aria-describedby]="nameField.invalid && nameField.touched ? 'td-name-error' : null">
  @if (nameField.invalid && nameField.touched) {
    <p id="td-name-error">Your name is required.</p>
  }

  <label for="td-age">Age</label>
  <input id="td-age" type="number" name="age" required
    min="0" max="120" step="1" pattern="[0-9]+"
    [(ngModel)]="user.age" #ageField="ngModel"
    [attr.aria-invalid]="ageField.invalid && ageField.touched"
    [attr.aria-describedby]="ageField.invalid && ageField.touched ? 'td-age-error' : null">
  @if (ageField.invalid && ageField.touched) {
    <p id="td-age-error">Enter a whole-number age from 0 to 120.</p>
  }

  <button type="submit" [disabled]="!myForm.valid">Submit</button>
  <button type="button" (click)="resetForm(myForm)">Reset</button>
</form>

<p>Name: {{ user.name }}</p>
<p>Age: {{ user.age }}</p>
@if (submitted) {
  <p>Submitted locally: {{ submitted.name }}, age {{ submitted.age }}.</p>
}

#nameField="ngModel" exposes that field's validation and interaction state. The name error checks the name field—not whether some other field has been touched. Angular's validator directives enforce required, min, max, and pattern; the digit pattern supplies the whole-number rule rather than relying on the spinner's step setting.

The labels connect to input IDs. When an error appears, aria-describedby associates its text with the corresponding field.

The Reactive Way

Reactive forms move the control model and validator functions into TypeScript. FormGroup and FormControl are model classes; [formGroup] and formControlName are the template bindings that connect that model to inputs.

Define a Typed Form Model

Save this as src/app/user-form.ts. A factory gives the component and its tests the same form definition.

import {FormControl, FormGroup, Validators} from '@angular/forms';

export function createUserForm() {
  return new FormGroup({
    name: new FormControl('', {
      nonNullable: true,
      validators: Validators.required,
    }),
    age: new FormControl<number | null>(null, {
      validators: [
        Validators.required,
        Validators.min(0),
        Validators.max(120),
        Validators.pattern('[0-9]+'),
      ],
    }),
  });
}

app.component.ts

import {Component} from '@angular/core';
import {FormGroupDirective, ReactiveFormsModule} from '@angular/forms';
import {User} from './user';
import {createUserForm} from './user-form';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [ReactiveFormsModule],
  templateUrl: './app.component.html',
})
export class AppComponent {
  readonly myForm = createUserForm();
  readonly fields = this.myForm.controls;
  submitted: User | null = null;

  submitForm(): void {
    if (!this.myForm.valid) {
      this.myForm.markAllAsTouched();
      return;
    }
    const {name, age} = this.myForm.getRawValue();
    if (age === null) return;
    this.submitted = new User(name, age);
  }

  resetForm(directive: FormGroupDirective): void {
    directive.resetForm({name: '', age: null});
    this.submitted = null;
  }
}

app.component.html

<form [formGroup]="myForm" #formDirective="ngForm"
  (ngSubmit)="submitForm()" novalidate>
  <label for="rf-name">Name</label>
  <input id="rf-name" type="text" formControlName="name" required
    [attr.aria-invalid]="fields.name.invalid && fields.name.touched"
    [attr.aria-describedby]="fields.name.invalid && fields.name.touched ? 'rf-name-error' : null">
  @if (fields.name.invalid && fields.name.touched) {
    <p id="rf-name-error">Your name is required.</p>
  }

  <label for="rf-age">Age</label>
  <input id="rf-age" type="number" formControlName="age" required
    min="0" max="120" step="1"
    [attr.aria-invalid]="fields.age.invalid && fields.age.touched"
    [attr.aria-describedby]="fields.age.invalid && fields.age.touched ? 'rf-age-error' : null">
  @if (fields.age.invalid && fields.age.touched) {
    <p id="rf-age-error">Enter a whole-number age from 0 to 120.</p>
  }

  <button type="submit" [disabled]="!myForm.valid">Submit</button>
  <button type="button" (click)="resetForm(formDirective)">Reset</button>
</form>

<p>Name: {{ fields.name.value }}</p>
<p>Age: {{ fields.age.value }}</p>
@if (submitted) {
  <p>Submitted locally: {{ submitted.name }}, age {{ submitted.age }}.</p>
}

Typed Values, Form State, and Reset Behavior

Reactive forms have been strictly typed by default since Angular 14. Here, the name control is FormControl<string>, while age is explicitly FormControl<number | null>. Validators don't narrow the TypeScript type, so the submission handler still handles null.

  • nonNullable: true makes an argument-free control reset restore its initial value rather than null.
  • For an enabled group, value excludes disabled controls. getRawValue() includes them; use it intentionally, not as a blanket submission rule.

These distinctions are covered in Angular's typed forms guide. Neither a type annotation nor nonNullable replaces runtime validation.

touched records blur; dirty records a user change in the UI. These examples wait for touched; use invalid && (touched || dirty) if you want errors while editing. Also, invalid isn't always the opposite of valid: a control can be PENDING or DISABLED. Checking !valid blocks submission during pending validation.

Both reset handlers call the form directive's resetForm(), which resets the submitted status as well as the form. In the reactive template, #formDirective="ngForm" refers to FormGroupDirective; it doesn't turn the form into a template-driven form.

Angular Template-Driven vs Reactive Forms: Choosing an Approach

ConcernTemplate-drivenReactive
Control modelCreated by template directivesConstructed explicitly in TypeScript
ValidationAttributes and validator directivesValidator functions attached to controls
Typed control valuesDoesn't offer the same typed control-model APITyped controls and inferred group structure
Changing collectionsTemplate repetition and control registrationExplicit collection APIs such as FormArray
TestingTemplate/directive integration is centralModel and validator tests can run without rendering
Main costRules become harder to trace as template logic growsMore explicit setup for a small form

The underlying differences are model ownership, validation configuration, and testing—not a claim that one approach produces a better-looking form. Angular's forms overview explains those trade-offs.

Decision rule: choose template-driven forms when the fields and rules fit comfortably in the template. Choose reactive forms when you need to reason about the form independently of its HTML: conditional sections, repeated controls, coordinated updates, or substantial validation tests.

What Reactive Forms Make More Explicit

Observable Streams for Values and Status

The form model isn't itself an observable. Its valueChanges stream emits value updates, and statusChanges emits recalculated validation status. Subscribe to the group's stream when your logic needs the updated group value, rather than reading it inside a child control's value callback.

For example, this optional fragment clears the previous submitted snapshot after an edit. Add the import and constructor to the reactive component; takeUntilDestroyed completes the subscription when the component is destroyed.

// Fragment: add this import to app.component.ts.
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
import type {User} from './user';
import {createUserForm} from './user-form';

// Fragment: class excerpt showing the constructor in context.
// Keep the existing component decorator, other imports, and methods.
export class AppComponent {
  readonly myForm = createUserForm();
  readonly fields = this.myForm.controls;
  submitted: User | null = null;

  constructor() {
    this.myForm.valueChanges
      .pipe(takeUntilDestroyed())
      .subscribe(() => {
        this.submitted = null;
      });
  }

  // ... keep the existing submitForm and resetForm methods.
}

If streams are new to you, start with our JavaScript observables introduction.

FormArray for Changing Lists of Controls

A FormArray manages an ordered collection whose controls can be added or removed at runtime. This model-only example demonstrates an editable alias list without involving a template.

import {FormArray, FormControl} from '@angular/forms';

const aliases = new FormArray([
  new FormControl('Ada', {nonNullable: true}),
]);
aliases.push(new FormControl('A. Lovelace', {nonNullable: true}));
aliases.removeAt(0);

console.log(aliases.getRawValue()); // ['A. Lovelace']

For a rendered version, see the dynamic collection example in Angular's reactive forms guide.

Dynamic and Async Validation

When rules change, addValidators() preserves existing validators, while setValidators() replaces them. Call updateValueAndValidity() after changing validators so the new rules take effect.

Both approaches support async validation, such as checking whether a username is available. An async validator returns a Promise or a completing Observable that resolves to errors or null; Angular runs it only after synchronous validation passes. Consider updateOn: 'blur' for checks that would otherwise send a request after every edit. See Angular's validation guide for both implementations.

Testing the Reactive Form Without Rendering

The benefit isn't that template-driven forms are untestable. Reactive forms let you instantiate the model, set values, and assert validation without driving DOM events; template-driven integration tests need to exercise the template and directives. Both still need UI tests for bindings, messages, and accessibility.

In an Angular workspace configured for Vitest, save this as src/app/user-form.spec.ts. For broader testing guidance, see our benefits of unit testing.

import {describe, expect, it} from 'vitest';
import {createUserForm} from './user-form';

describe('user form', () => {
  it('enforces the required and age rules', () => {
    const form = createUserForm();
    expect(form.invalid).toBe(true);

    form.setValue({name: 'Ada', age: 36});
    expect(form.valid).toBe(true);

    for (const age of [-1, 121, 2.5, null]) {
      form.controls.age.setValue(age);
      expect(form.invalid).toBe(true);
    }

    form.controls.name.setValue('');
    expect(form.controls.name.hasError('required')).toBe(true);
  });

  it('resets the name and nullable age', () => {
    const form = createUserForm();
    form.setValue({name: 'Ada', age: 36});
    form.reset();
    expect(form.getRawValue()).toEqual({name: '', age: null});
  });
});

Common Mistakes

  • Combining bindings on one input. Don't attach [(ngModel)] and formControlName to the same control. Using ngModel with reactive form directives is deprecated; using separate approaches for separate forms is fine.
  • Using form-level interaction state for a field error. Check the field's own state, as both examples do.
  • Treating required as a complete name policy. These examples require a nonempty name; add an explicit whitespace or normalization policy if your application needs one. For more examples, see Angular form validation examples.
  • Relying on a disabled button for correctness. Keep the submission guard. For a real save, track in-flight requests, prevent duplicate submissions, and show failures without discarding the draft.
  • Trusting browser validation as a security boundary. Validate accepted values again on the server; client-side checks can be bypassed.

Conclusion

Don't introduce a reactive control tree merely because a form exists. Use it when explicit state and independently testable rules reduce complexity; otherwise, a small template-driven form is a reasonable choice.

FAQ

What is the difference between template-driven and reactive forms in Angular?

Template-driven forms use directives such as ngModel to build and synchronize controls from the template. Reactive forms construct FormControl and FormGroup models explicitly in TypeScript and bind them to the template.

When should I use reactive forms instead of template-driven forms?

Choose reactive forms when dynamic controls, coordinated field behavior, typed values, or model-level validation tests justify the additional setup. Template-driven forms remain a good fit for small forms with straightforward rules.

Can template-driven and reactive forms be used in the same Angular application?

Yes. Choose an approach per form and import FormsModule or ReactiveFormsModule where needed. Don't combine ngModel and reactive-form bindings on the same input.

How do typed reactive forms work in Angular?

Angular infers control and group types from their declarations. A control initialized with a string is normally nullable because reset can produce null; nonNullable changes that reset behavior, but it doesn't replace validators.

What version of Angular do these examples target?

The reference baseline is Angular 22 with standalone components and component-level forms imports. Run the examples in your workspace before relying on them in production.

Further Reading

Written by Sam Erickson

Software engineer. I write every article on StackChief myself. Author of Mastering the Tech Interview. More about me