Forms in Angular

Template-driven vs reactive forms, and a complete reactive form example with validation.

Two form models, deliberately kept separate

Angular ships two entirely distinct APIs for building forms, and — unlike most of the framework, where there's one clear modern default — both remain fully supported and genuinely useful for different situations. Understanding which is which, and why they're organized as separate modules at all, matters before writing any real form code.

Template-driven forms Reactive forms
Form model lives in The template (ngModel directives) The component class (FormGroup/FormControl objects)
Import needed FormsModule ReactiveFormsModule
Validation Template-attribute-based (required, minlength) Defined programmatically alongside the form model
Testability Harder — logic is embedded in the template Easier — the form model is a plain object, testable without rendering
Best fit Small, simple forms (a search box, a single field) Anything with real validation, dynamic fields, or complex logic

Template-driven forms

A template-driven form's state lives mostly in the template itself, bound with ngModel — the two-way binding directive that mirrors v-model in Vue or bind:value in Svelte:

Typescript
import { Component } from "@angular/core";
import { FormsModule } from "@angular/forms";

@Component({
  selector: "app-search",
  standalone: true,
  imports: [FormsModule],
  template: `
    <input [(ngModel)]="searchTerm" placeholder="Search..." />
    <p>Searching for: {{ searchTerm }}</p>
  `,
})
export class SearchComponent {
  searchTerm = "";
}

[(ngModel)] is Angular's "banana in a box" syntax — combining property binding ([ngModel], data going in) and event binding ((ngModelChange), an update coming out) into one two-way binding directive, requiring FormsModule to be imported. This is a reasonable, low-ceremony choice for something as simple as a single search field — it becomes unwieldy quickly for anything with cross-field validation or fields that need to be added/removed dynamically, which is exactly where reactive forms take over.

Reactive forms: building the model in the component

A reactive form's model — every field, its current value, and its validators — is built explicitly in the component class as a FormGroup of FormControls, with the template simply binding to that already-existing model rather than defining it:

Typescript
import { Component } from "@angular/core";
import { ReactiveFormsModule, FormGroup, FormControl, Validators } from "@angular/forms";

@Component({
  selector: "app-signup",
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="signupForm" (ngSubmit)="onSubmit()">
      <input formControlName="email" placeholder="Email" />
      <input formControlName="password" type="password" placeholder="Password" />
      <button type="submit" [disabled]="signupForm.invalid">Sign up</button>
    </form>
  `,
})
export class SignupComponent {
  signupForm = new FormGroup({
    email: new FormControl("", [Validators.required, Validators.email]),
    password: new FormControl("", [Validators.required, Validators.minLength(8)]),
  });

  onSubmit() {
    if (this.signupForm.valid) {
      console.log(this.signupForm.value); // { email: "...", password: "..." }
    }
  }
}

[formGroup]="signupForm" binds the whole form model to the <form> element; formControlName="email" links one specific input to one FormControl inside that group. Validators.required, .email, and .minLength(8) are Angular's built-in validators, passed as an array to each FormControl's constructor — the form's overall .valid/.invalid state is automatically derived from every control's individual validity, which is exactly what [disabled]="signupForm.invalid" reads to keep the submit button disabled until the whole form passes.

A complete example: a reactive form with validation feedback

A real form typically needs to show which field is invalid and why, not just block submission silently:

Typescript
import { Component } from "@angular/core";
import { ReactiveFormsModule, FormGroup, FormControl, Validators } from "@angular/forms";

@Component({
  selector: "app-signup",
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="signupForm" (ngSubmit)="onSubmit()">
      <div>
        <input formControlName="email" placeholder="Email" />
        @if (email?.invalid && email?.touched) {
          <p class="error">
            @if (email?.errors?.['required']) { Email is required. }
            @if (email?.errors?.['email']) { Enter a valid email address. }
          </p>
        }
      </div>

      <div>
        <input formControlName="password" type="password" placeholder="Password" />
        @if (password?.invalid && password?.touched) {
          <p class="error">Password must be at least 8 characters.</p>
        }
      </div>

      <button type="submit" [disabled]="signupForm.invalid">Sign up</button>
    </form>
  `,
})
export class SignupComponent {
  signupForm = new FormGroup({
    email: new FormControl("", [Validators.required, Validators.email]),
    password: new FormControl("", [Validators.required, Validators.minLength(8)]),
  });

  get email() {
    return this.signupForm.get("email");
  }

  get password() {
    return this.signupForm.get("password");
  }

  onSubmit() {
    if (this.signupForm.invalid) {
      this.signupForm.markAllAsTouched(); // surface every field's error, even untouched ones
      return;
    }
    console.log("Submitting:", this.signupForm.value);
  }
}

.touched is what keeps an error message from appearing the instant the page loads, before the visitor has even interacted with the field — it only becomes true once the control has been focused and blurred at least once. .errors is an object keyed by whichever validator(s) currently fail ({ required: true }, { email: true }, and so on), which is what makes it possible to show a specific, meaningful message per validation rule rather than one generic "invalid" state. markAllAsTouched() is the standard way to reveal every field's validation errors at once when a visitor tries to submit an already-invalid form without having touched every field individually first.

Common mistakes

  • Mixing FormsModule (template-driven) and ReactiveFormsModule (reactive) directives on the same form — [(ngModel)] alongside formControlName on one form is a common source of confusing, hard-to-debug behavior; pick one model per form.
  • Checking only .invalid without also checking .touched (or .dirty) before showing an error message — without it, every field shows as invalid the instant the page loads, before the visitor has typed anything at all.
  • Forgetting [disabled]="form.invalid" (or an equivalent check inside the submit handler) — without either, an invalid form can still be submitted, and validation becomes purely cosmetic.
  • Reaching for reactive forms' full FormGroup/FormControl ceremony for a single, trivial input (a lone search box) where a simple [(ngModel)] binding would be simpler and perfectly adequate.