| |

Angular 31 🅰️ Template-Driven Forms

Template-driven forms are Angular’s declarative approach to handling user input. Instead of building the form model in the component class, you define the structure and validation directly in the template using directives. The NgModel directive creates and manages the underlying FormControl instances, and two-way data binding with [(ngModel)] keeps the view and component model synchronized. This approach is ideal for simple forms with straightforward validation — login pages, contact forms, basic settings panels. The tradeoff is control: template-driven forms are less explicit, harder to unit test, and less efficient for change detection than reactive forms. This chapter covers the mechanics, the validation patterns, the array handling with @for, and the situations where template-driven forms are the right choice.

Key point: Template-driven forms rely on FormsModule, ngModel, and the name attribute to register controls. The template is the source of truth — the form model is implicit, created and managed by NgModel directives. Validation uses HTML5 attributes (required, minlength, pattern, email) plus custom validator directives registered with NG_VALIDATORS. Template reference variables (#field="ngModel") expose control state (valid, invalid, touched, dirty, errors) for error messages. Arrays of controls require unique name attributes per iteration, typically using $index from @for.


Why template-driven forms exist

Angular offers two form-building approaches, and the distinction is architectural. Reactive forms put the form model in the component class — you build FormControl, FormGroup, and FormArray instances explicitly, and the template binds to them. Template-driven forms flip this: the template defines the form, and the directives create the model behind the scenes.

The implicit model. In a template-driven form, you do not write new FormControl(). The NgModel directive does it for you when it encounters an element with [(ngModel)] inside a <form> tag . The name attribute is the key that registers the control with the parent NgForm directive . Without name, the control is not registered and the form model does not include it.

Why this is easier for simple cases. For a form with three fields and basic validation, reactive forms require importing ReactiveFormsModule, building a FormGroup in the constructor, and typing each control name twice — once in the class and once in the template. Template-driven forms require importing FormsModule, adding [(ngModel)] and name to each input, and that is it. The form model is inferred from the template structure.

Why this is harder for complex cases. The implicit model is less accessible. Testing requires rendering the template and simulating user input, because the form state lives in directives rather than the component class . Complex validation logic — cross-field checks, async validators, conditional required fields — requires custom directives, which are more ceremony than reactive validators . And change detection is less efficient because NgModel mutates the model in place rather than producing new immutable values .

Why the template is the source of truth. In reactive forms, the FormControl instance is the authority — the template binds to it. In template-driven forms, the template defines the controls, and the directive instances are created to match. This inversion is why template-driven forms feel more “natural” for simple cases and more limiting for complex ones.


Setting up a template-driven form

The setup requires three things: importing FormsModule, wrapping inputs in a <form> tag, and adding [(ngModel)] with a name attribute to each input.

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

@Component({
  selector: 'app-contact-form',
  standalone: true,
  imports: [FormsModule],
  template: `
    <form #contactForm="ngForm" (ngSubmit)="onSubmit(contactForm)">
      <label for="firstName">First Name:</label>
      <input
        id="firstName"
        name="firstName"
        type="text"
        [(ngModel)]="contact.firstName"
        required
      />

      <label for="lastName">Last Name:</label>
      <input
        id="lastName"
        name="lastName"
        type="text"
        [(ngModel)]="contact.lastName"
        required
      />

      <button type="submit" [disabled]="contactForm.invalid">Submit</button>
    </form>
  `,
})
export class ContactFormComponent {
  contact = { firstName: '', lastName: '' };

  onSubmit(form: any) {
    console.log(form.value);
  }
}

The #contactForm="ngForm" template reference variable exports the NgForm directive instance, which provides access to the form’s value, valid, invalid, touched, and dirty properties . The name attribute on each input registers it with NgForm — the control becomes a property of form.value under that name . The [(ngModel)] binding creates two-way synchronization between the input and the contact.firstName property .

Why name is mandatory. Without name, Angular cannot register the control in the form model. The NgModel directive requires a parent NgForm (provided automatically by the <form> tag) and a name to create a named FormControl . Missing name produces a runtime error: “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.”

Why the form value is grouped by name. form.value is an object whose keys are the name attributes. form.value.firstName is the current value of the first name input. This is how the implicit model is structured — it mirrors the name attributes in the template.


Validation with built-in validators

Template-driven validation uses HTML5 attributes as validators. Angular recognizes required, minlength, maxlength, pattern, email, min, and max when applied to elements with ngModel . These become validation rules on the corresponding FormControl.

<input
  id="email"
  name="email"
  type="email"
  [(ngModel)]="contact.email"
  required
  email
  #emailField="ngModel"
/>

<div *ngIf="emailField.invalid && emailField.touched">
  <p *ngIf="emailField.errors?.['required']">Email is required</p>
  <p *ngIf="emailField.errors?.['email']">Invalid email format</p>
</div>

The #emailField="ngModel" template reference variable exposes the control’s state . The errors object contains a key for each failing validator: { required: true } or { email: true }. The touched property is true after the user has blurred the field; checking invalid && touched prevents showing errors before the user has interacted .

Why the errors object is the key. Each validator adds a property to errors when it fails. required adds { required: true }, minlength adds { minlength: { requiredLength: 3, actualLength: 1 } }, and pattern adds { pattern: { requiredPattern: '...', actualValue: '...' } }. This structured format lets you write precise error messages.

The control state CSS classes. Angular applies classes to inputs based on state: ng-valid / ng-invalid, ng-touched / ng-untouched, ng-pristine / ng-dirty . These can be used for visual feedback without any template logic.

input.ng-invalid.ng-touched {
  border-color: red;
}

Why touched and dirty matter. touched is set after the user blurs the control. dirty is set after the user changes the value. Showing validation errors only when touched || dirty is the standard pattern — it avoids displaying “required” errors on a form the user has not started filling out .


Custom validators as directives

Custom validation logic requires a directive that implements the Validator interface and registers itself with NG_VALIDATORS .

import { Directive, Input } from '@angular/core';
import { AbstractControl, NG_VALIDATORS, ValidationErrors, Validator } from '@angular/forms';

@Directive({
  selector: '[appForbiddenName]',
  standalone: true,
  providers: [
    { provide: NG_VALIDATORS, useExisting: ForbiddenNameDirective, multi: true },
  ],
})
export class ForbiddenNameDirective implements Validator {
  @Input('appForbiddenName') forbiddenName = '';

  validate(control: AbstractControl): ValidationErrors | null {
    const forbidden = new RegExp(this.forbiddenName, 'i').test(control.value);
    return forbidden ? { forbiddenName: { value: control.value } } : null;
  }
}

The useExisting provider is critical — it registers the directive instance as the validator, so the forbiddenName input binding is available when validate runs . Using useClass would create a separate instance without the bound input.

Why custom validators are directives. In template-driven forms, there is no place to pass a validator function directly. The template syntax only supports directives. So the validator function is wrapped in a directive, and the directive is applied to the input as an attribute .

Using the validator:

<input
  name="username"
  [(ngModel)]="username"
  #usernameField="ngModel"
  appForbiddenName="admin"
  required
/>
<div *ngIf="usernameField.errors?.['forbiddenName']">
  This username is not allowed
</div>

The directive’s selector [appForbiddenName] matches the attribute, and the value "admin" binds to the forbiddenName input .


Arrays of controls with @for

Repeated form controls — phone numbers, addresses, line items — require an array in the component and a loop in the template. The critical constraint is that each control must have a unique name .

interface PhoneEntry {
  number: string;
}

@Component({ /* ... */ })
export class ContactFormComponent {
  contact = {
    name: '',
    phoneNumbers: [{ number: '' }] as PhoneEntry[],
  };

  addPhone() {
    this.contact.phoneNumbers.push({ number: '' });
  }

  removePhone(index: number) {
    this.contact.phoneNumbers.splice(index, 1);
  }
}
<div *ngFor="let phone of contact.phoneNumbers; let i = index">
  <input
    [name]="'phoneNumber_' + i"
    [(ngModel)]="phone.number"
    #phoneNum="ngModel"
    required
    pattern="[0-9]+"
  />
  <div *ngIf="phoneNum.invalid && phoneNum.touched">
    <p *ngIf="phoneNum.errors?.['required']">Number is required</p>
    <p *ngIf="phoneNum.errors?.['pattern']">Digits only</p>
  </div>
  <button type="button" (click)="removePhone(i)">Remove</button>
</div>
<button type="button" (click)="addPhone()">Add Phone</button>

The [name]="'phoneNumber_' + i" binding produces unique names: phoneNumber_0, phoneNumber_1, and so on . Without unique names, the second control silently overwrites the first in the form model, and both rows show the same value.

Why $index is required. Angular’s @for block provides an implicit $index variable holding the current iteration number. Embedding it in the name gives each control a distinct identity in the NgForm model . The same pattern applies to id and for attributes for label association.

Why array mutation drives the DOM. When addPhone pushes a new entry, Angular’s change detection re-runs the loop and renders a new row. When removePhone splices an entry, the row is destroyed and its control is deregistered from NgForm . Form validity recalculates automatically.


Complete Example Session

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

interface PhoneEntry {
  number: string;
}

@Component({
  selector: 'app-contact-editor',
  standalone: true,
  imports: [FormsModule],
  template: `
    <form #contactForm="ngForm" (ngSubmit)="onSubmit(contactForm)">
      <!-- Name group -->
      <fieldset ngModelGroup="name">
        <label for="firstName">First Name:</label>
        <input
          id="firstName"
          name="firstName"
          type="text"
          [(ngModel)]="contact.firstName"
          required
          #firstName="ngModel"
        />
        <div *ngIf="firstName.invalid && firstName.touched">
          First name is required.
        </div>

        <label for="lastName">Last Name:</label>
        <input
          id="lastName"
          name="lastName"
          type="text"
          [(ngModel)]="contact.lastName"
          required
          #lastName="ngModel"
        />
        <div *ngIf="lastName.invalid && lastName.touched">
          Last name is required.
        </div>
      </fieldset>

      <!-- Email -->
      <label for="email">Email:</label>
      <input
        id="email"
        name="email"
        type="email"
        [(ngModel)]="contact.email"
        required
        email
        #email="ngModel"
      />
      <div *ngIf="email.invalid && email.touched">
        <p *ngIf="email.errors?.['required']">Email is required.</p>
        <p *ngIf="email.errors?.['email']">Invalid email format.</p>
      </div>

      <!-- Phone numbers -->
      <div *ngFor="let phone of contact.phoneNumbers; let i = index">
        <input
          [name]="'phoneNumber_' + i"
          type="text"
          [(ngModel)]="phone.number"
          pattern="[0-9]+"
          #phoneNum="ngModel"
        />
        <div *ngIf="phoneNum.invalid && phoneNum.touched">
          <p *ngIf="phoneNum.errors?.['pattern']">Digits only</p>
        </div>
        <button type="button" (click)="removePhone(i)">Remove</button>
      </div>
      <button type="button" (click)="addPhone()">+ Add Phone Number</button>

      <button type="submit" [disabled]="contactForm.invalid">Save Contact</button>
    </form>
  `,
})
export class ContactEditorComponent {
  contact = {
    firstName: '',
    lastName: '',
    email: '',
    phoneNumbers: [] as PhoneEntry[],
  };

  addPhone() {
    this.contact.phoneNumbers.push({ number: '' });
  }

  removePhone(index: number) {
    this.contact.phoneNumbers.splice(index, 1);
  }

  onSubmit(form: any) {
    console.log('Form valid:', form.valid);
    console.log('Form value:', form.value);
    console.log('Model:', this.contact);
  }
}

The ngModelGroup="name" wraps the name fields in a nested FormGroup, accessible as form.value.name . The array loop demonstrates unique naming with $index. The submit button is disabled when contactForm.invalid.


Quick Reference

Setup Requirements

ItemPurpose
FormsModuleProvides ngModel, NgForm directives
<form #f="ngForm">Creates the form model
name="..."Registers control in form model
[(ngModel)]="prop"Two-way binding

Control State Properties

PropertyMeaning
valid / invalidPasses/fails all validators
touched / untouchedBlurred/not blurred
dirty / pristineChanged/not changed
errorsValidator failures object
pendingAsync validation in progress

Built-in Validators

AttributeValidates
requiredNon-empty
minlength="N"Minimum characters
maxlength="N"Maximum characters
pattern="..."Regex match
emailEmail format
min="N" / max="N"Numeric range

Template Reference Variables

VariablePurpose
#form="ngForm"Whole form
#field="ngModel"Single control
#group="ngModelGroup"Nested group

Array Controls

RequirementHow
Unique name[name]="'prefix_' + $index"
Unique id[id]="'prefix_' + $index"
Label association[for]="'prefix_' + $index"

Best Practices

Do This:

<input name="email" [(ngModel)]="contact.email" required email #email="ngModel" /> <!-- ✅ -->

<div *ngIf="email.invalid && email.touched">Email is required</div> <!-- ✅ -->

<fieldset ngModelGroup="name"> <!-- ✅ -->
  <input name="firstName" [(ngModel)]="name.first" required />
  <input name="lastName" [(ngModel)]="name.last" required />
</fieldset>
// Use useExisting for custom validators
providers: [{ provide: NG_VALIDATORS, useExisting: MyValidator, multi: true }] // ✅

Don’t Do This:

<input [(ngModel)]="contact.email" /> <!-- ⚠️ missing name — not registered -->

<div *ngIf="email.invalid">Email is required</div> <!-- ⚠️ shows before interaction -->

<input name="phone" *ngFor="..." /> <!-- ⚠️ duplicate name — controls collide -->

<!-- ⚠️ Don't use useClass for custom validators — creates a new instance without inputs -->

Common Pitfalls

PitfallProblemSolution
Missing nameControl not in form modelAdd name="fieldName"
Duplicate name in loopControls overwrite each other[name]="'field_' + i"
Showing errors on untouchedPremature error displayCheck touched || dirty
useClass for validatorsNew instance, inputs missingUse useExisting
Forgetting FormsModulengModel not recognizedImport in component
Mutating arrays without change detectionDOM not updatedUse push/splice

Real-World Examples

1. Login form

<form #login="ngForm" (ngSubmit)="onLogin(login)">
  <input name="email" type="email" [(ngModel)]="email" required email />
  <input name="password" type="password" [(ngModel)]="password" required minlength="8" />
</form>

2. Contact form with name group

<fieldset ngModelGroup="name">
  <input name="first" [(ngModel)]="contact.first" required />
  <input name="last" [(ngModel)]="contact.last" required />
</fieldset>

3. Dynamic phone list

<div *ngFor="let p of phones; let i = index">
  <input [name]="'phone_' + i" [(ngModel)]="p.number" pattern="[0-9]+" />
  <button (click)="remove(i)">X</button>
</div>

4. Custom forbidden name validator

<input name="username" [(ngModel)]="user.name" appForbiddenName="admin" />

5. Cross-field validation

<form #f="ngForm" appPasswordMatch>
  <input name="password" type="password" [(ngModel)]="pwd" />
  <input name="confirm" type="password" [(ngModel)]="confirm" />
  <div *ngIf="f.errors?.['passwordMismatch'] && f.touched">Passwords must match</div>
</form>

6. Async username check

<input name="username" [(ngModel)]="user.name" appUniqueUsername />
<div *ngIf="model.pending">Checking...</div>

7. Reset form

<form #f="ngForm">
  <button type="reset">Reset</button>
</form>

8. Disable submit on invalid

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

9. Display form value

<pre>{{ f.value | json }}</pre>

10. Conditional required field

<input name="other" [(ngModel)]="other" [required]="needsOther" />

Visual: Template-Driven Form Model

┌──────────────────────────────────────────────────────┐
│  <form #f="ngForm">                                  │
│    │                                                 │
│    ├── <input name="firstName" [(ngModel)]="first" /> │
│    │        │                                        │
│    │        └── NgModel directive creates FormControl│
│    │            registered as "firstName"            │
│    │                                                 │
│    ├── <input name="lastName" [(ngModel)]="last" />   │
│    │        │                                        │
│    │        └── FormControl registered as "lastName" │
│    │                                                 │
│    └── <button type="submit">Submit</button>         │
│                                                      │
│  NgForm instance (exported as #f):                   │
│    value = { firstName: "...", lastName: "..." }     │
│    valid = true/false                                │
│    touched = true/false                              │
│                                                      │
└──────────────────────────────────────────────────────┘

Visual: Data Flow

┌──────────────────────────────────────────────────────┐
│  VIEW → MODEL                                        │
│                                                      │
│  User types "Blue"                                   │
│       │                                              │
│       ▼                                              │
│  input event fires                                   │
│       │                                              │
│       ▼                                              │
│  ControlValueAccessor calls setValue("Blue")         │
│       │                                              │
│       ▼                                              │
│  FormControl emits valueChanges                      │
│       │                                              │
│       ▼                                              │
│  NgModel.viewToModelUpdate() emits ngModelChange     │
│       │                                              │
│       ▼                                              │
│  [(ngModel)] binding updates component property      │
│                                                      │
├──────────────────────────────────────────────────────┤
│  MODEL → VIEW                                        │
│                                                      │
│  Component property changes to "Red"                 │
│       │                                              │
│       ▼                                              │
│  Change detection runs                               │
│       │                                              │
│       ▼                                              │
│  NgModel.ngOnChanges() queues async update           │
│       │                                              │
│       ▼                                              │
│  Next tick: FormControl.setValue("Red")              │
│       │                                              │
│       ▼                                              │
│  ControlValueAccessor updates input element          │
│                                                      │
└──────────────────────────────────────────────────────┘

Visual: Validation Error Display

┌──────────────────────────────────────────────────────┐
│  <input name="email" [(ngModel)]="email"             │
│         required email #email="ngModel" />           │
│                                                      │
│  #email = NgModel directive instance                 │
│                                                      │
│  email.valid     → true/false                        │
│  email.invalid   → true/false                        │
│  email.touched   → true/false                        │
│  email.errors    → { required: true }                │
│                    or { email: true }                │
│                    or null                           │
│                                                      │
│  <div *ngIf="email.invalid && email.touched">        │
│    <p *ngIf="email.errors?.['required']">            │
│      Email is required                               │
│    </p>                                              │
│    <p *ngIf="email.errors?.['email']">               │
│      Invalid format                                  │
│    </p>                                              │
│  </div>                                              │
│                                                      │
└──────────────────────────────────────────────────────┘

Visual: Unique Names in Loops

┌──────────────────────────────────────────────────────┐
│  WITHOUT unique names:                               │
│                                                      │
│  <input name="phone" *ngFor="let p of phones" />     │
│                                                      │
│  Row 0: name="phone"  → FormControl "phone"          │
│  Row 1: name="phone"  → overwrites "phone"           │
│  Row 2: name="phone"  → overwrites again             │
│                                                      │
│  All rows show the same value. ❌                    │
│                                                      │
├──────────────────────────────────────────────────────┤
│  WITH unique names:                                  │
│                                                      │
│  <input [name]="'phone_' + i" *ngFor="...; let i" /> │
│                                                      │
│  Row 0: name="phone_0" → FormControl "phone_0"       │
│  Row 1: name="phone_1" → FormControl "phone_1"       │
│  Row 2: name="phone_2" → FormControl "phone_2"       │
│                                                      │
│  Each row is independent. ✅                         │
│                                                      │
└──────────────────────────────────────────────────────┘

Visual: When to Use Template-Driven

┌──────────────────────────────────────────────────────┐
│  How complex is the form?                            │
│                                                      │
│  Simple (login, contact, settings)                   │
│       │                                              │
│       ▼                                              │
│  TEMPLATE-DRIVEN                                     │
│  - Easy to set up                                    │
│  - Validation in HTML                                │
│  - Less component code                               │
│                                                      │
│  Complex (dynamic, cross-field, async)               │
│       │                                              │
│       ▼                                              │
│  REACTIVE FORMS                                      │
│  - Explicit model                                    │
│  - Programmatic validators                           │
│  - Testable without DOM                              │
│                                                      │
└──────────────────────────────────────────────────────┘

Summary

AspectTemplate-Driven
ModuleFormsModule
Model locationImplicit, in directives
Binding[(ngModel)]
Registrationname attribute
ValidationHTML5 attributes + directives
State accessTemplate reference variables
ArraysUnique name per $index
TestingRequires DOM rendering
Best forSimple forms

Key takeaways:

  • Template-driven forms define the form in the templateNgModel directives create the underlying FormControl instances automatically
  • FormsModule must be imported for ngModel and NgForm to work
  • The name attribute is mandatory for each control; it registers the control in the form model and without it the control is not part of the form
  • [(ngModel)] is two-way binding — it synchronizes the view and component model
  • Template reference variables (#field="ngModel") expose control state like valid, touched, dirty, and errors for validation messages
  • Built-in validators are HTML5 attributesrequired, minlength, pattern, email — and custom validators are directives registered with NG_VALIDATORS
  • Arrays require unique names — use $index from @for to generate phone_0, phone_1, etc.
  • Show errors only when touched || dirty — avoid displaying validation messages before the user has interacted
  • Template-driven forms are best for simple cases — for complex validation, dynamic forms, or testability, reactive forms are the better choice

Remember: Template-driven forms trade explicit control for declarative simplicity. The template is the source of truth, NgModel does the bookkeeping, and name attributes tie it together. For login pages and basic contact forms, this is the right tool. For anything with cross-field logic, dynamic arrays of arbitrary size, or heavy unit testing, the implicit model becomes a limitation — and reactive forms become the better investment.


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!