Skip to content
Angular ng forms 4 min read

Template-Driven Forms

Template-driven forms let you build a working, validated form almost entirely in the HTML template, with very little TypeScript. You attach a handful of directives — chiefly ngModel and ngForm — and Angular quietly assembles the underlying FormControl/FormGroup model for you behind the scenes. For small, mostly-static forms like a login box, a contact form, or a settings panel, this is the fastest, most readable approach Angular offers.

Getting started with FormsModule

Everything template-driven lives in FormsModule. Import it into your standalone component and you immediately get access to the ngModel, ngForm, and validation directives. Without it, [(ngModel)] will throw a template error.

import { Component } from '@angular/core';
import { FormsModule, NgForm } from '@angular/forms';
import { JsonPipe } from '@angular/common';

@Component({
  selector: 'app-contact',
  standalone: true,
  imports: [FormsModule, JsonPipe],
  template: `
    <form #f="ngForm" (ngSubmit)="submit(f)">
      <label>
        Name
        <input name="name" [(ngModel)]="model.name" required minlength="2" />
      </label>

      <label>
        Email
        <input name="email" type="email" [(ngModel)]="model.email" required email />
      </label>

      <button type="submit" [disabled]="f.invalid">Send</button>
    </form>

    <pre>{{ f.value | json }}</pre>
  `,
})
export class ContactComponent {
  model = { name: '', email: '' };

  submit(form: NgForm): void {
    if (form.valid) {
      console.log('Submitting', form.value);
    }
  }
}

Output:

Submitting { name: 'Ada Lovelace', email: '[email protected]' }

The component class is nearly empty — the directives do the wiring. That is the core appeal of the template-driven style.

ngModel and two-way binding

ngModel is the heart of the approach. Wrapped in the banana-in-a-box syntax [(ngModel)], it creates a two-way binding so the input value and your component property stay in sync. Crucially, every input that participates in a form must have a name attribute — that name becomes the key in the generated form model.

You can also use ngModel in three other modes:

SyntaxDirectionUse it when
[(ngModel)]="prop"Two-wayYou want the value mirrored on a property
[ngModel]="prop"One-way inYou only push values into the control
ngModel (standalone)Registers controlYou read state via the template reference only
(ngModelChange)="fn($event)"One-way outYou react to each change manually

A common gotcha: forgetting the name attribute. Without it, ngModel cannot register the control with the parent ngForm, and the value silently disappears from form.value.

Tracking form state with ngForm

When you place a <form> element inside a component that imports FormsModule, Angular automatically attaches the ngForm directive. Exporting it with #f="ngForm" gives you a template reference to the whole form’s state. The same is true per-control: #name="ngModel" exposes one control’s status.

Both expose the same boolean status flags, which you use to drive your UI:

PropertyMeaning
valid / invalidWhether all validators pass
pristine / dirtyWhether the value has changed
touched / untouchedWhether the control has been blurred
submittedWhether the form has been submitted
valueThe aggregated { name: value } model object

Built-in validation directives

Template-driven forms reuse standard HTML validation attributes, but Angular intercepts them and feeds the results into the form model. You get required, minlength, maxlength, pattern, and email out of the box. Combine a control reference with the new @if control flow to display messages only after the user interacts.

<form #f="ngForm" (ngSubmit)="submit(f)">
  <input
    name="username"
    [(ngModel)]="model.username"
    #username="ngModel"
    required
    minlength="3"
    pattern="[a-z0-9]+"
  />

  @if (username.invalid && (username.dirty || username.touched)) {
    <div class="errors">
      @if (username.errors?.['required']) {
        <p>Username is required.</p>
      }
      @if (username.errors?.['minlength']) {
        <p>At least 3 characters, please.</p>
      }
      @if (username.errors?.['pattern']) {
        <p>Lowercase letters and digits only.</p>
      }
    </div>
  }

  <button type="submit" [disabled]="f.invalid">Save</button>
</form>

Each failed validator adds an entry to the control’s errors object, keyed by the validator name. Checking dirty || touched ensures you don’t scold the user before they’ve typed anything.

Grouping fields with ngModelGroup

For nested data, ngModelGroup creates a sub-FormGroup inside the form. This keeps related fields together and mirrors the shape of your backend payload.

<form #f="ngForm" (ngSubmit)="submit(f)">
  <fieldset ngModelGroup="address">
    <input name="street" ngModel required />
    <input name="city" ngModel required />
    <input name="zip" ngModel required pattern="\d{5}" />
  </fieldset>

  <button type="submit" [disabled]="f.invalid">Continue</button>
</form>

The resulting f.value becomes { address: { street, city, zip } }, and the group is invalid whenever any child control is invalid.

Best Practices

  • Always add a unique name attribute to every ngModel input — it is required for the control to register.
  • Gate error messages behind dirty || touched (or f.submitted) so users aren’t warned on first paint.
  • Keep template-driven forms for small, static forms; reach for reactive forms once logic, typing, or dynamic fields are involved.
  • Use ngModelGroup to mirror nested payload structures instead of flattening everything onto one model.
  • Disable the submit button with [disabled]="f.invalid", but always re-validate on the server — client validation is UX, not security.
  • Never combine ngModel and formControlName on the same control; mixing the two form strategies is explicitly discouraged.
Last updated June 14, 2026
Was this helpful?