Angular Template-Driven Forms Cheatsheet: When ngModel Is Still the Right Tool [2026]

Link copied
Angular Template-Driven Forms Cheatsheet: When ngModel Is Still the Right Tool [2026]

Angular Template-Driven Forms Cheatsheet: When ngModel Is Still the Right Tool [2026]

Angular Tutorial Module 4: Forms Lesson 4.3

Template-driven forms — the ngModel + template-reference-variable approach — were Angular's first forms API and remain the simplest one to write for tiny use cases. In 2026 they sit firmly in third place behind signal forms and reactive forms, but they earn their keep in a few specific scenarios. This lesson is a focused cheatsheet for when they fit and how to use them safely.

This is lesson 4.3, following lesson 4.2 on reactive forms. Read those first — they explain why most forms in modern Angular do NOT use template-driven.

The shape #

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

@Component({
  selector: 'app-quick-form',
  imports: [FormsModule],
  template: `
    <form #f="ngForm" (ngSubmit)="submit(f.value)">
      <input name="email" [(ngModel)]="email" required email />
      @if (f.controls['email']?.errors?.['required']) { <p>Email required</p> }

      <input name="password" type="password" [(ngModel)]="password" required minlength="8" />
      @if (f.controls['password']?.errors?.['minlength']) { <p>Min 8 chars</p> }

      <button type="submit" [disabled]="f.invalid">Submit</button>
    </form>
  `,
})
export class QuickFormComponent {
  email = '';
  password = '';

  submit(value: any) {
    console.log('Submit:', value);
  }
}

The mechanics:

  1. #f="ngForm" captures the form's NgForm directive instance for template binding
  2. [(ngModel)]="email" two-way-binds the input to a component property
  3. name="email" registers the input as a control in the form (required for template-driven!)
  4. required, email, minlength="8" are validator DIRECTIVES (not functions) — applied as HTML attributes

No FormBuilder, no FormGroup, no class-side schema. The form structure lives entirely in the template.

When template-driven is the right call #

Three honest cases:

1. Prototypes and one-off forms #

For a quick admin tool, a hackathon, a sandbox component — template-driven is the fastest path to a working form. No imports beyond FormsModule, no schema to maintain, just bind and submit.

2. Tutorial code and demos #

When the form is incidental to what you're teaching, template-driven keeps the code shorter and the reader focused on the actual point.

3. Trivial forms in primarily back-office UIs #

Search boxes, filter dropdowns, login forms in internal tools — anything where you'd reach for HTML's native <input> if Angular wasn't involved, template-driven is the closest Angular equivalent.

For everything else (multi-field forms with validation, anything user-facing, anything with cross-field logic), signal forms or reactive forms are the right tool.

When template-driven is the wrong call #

Four anti-patterns:

  1. Complex validation — built-in validators only (required, email, minlength, maxlength, pattern, min, max). Custom validators need a directive wrapper — verbose and error-prone.
  2. Dynamic forms — adding/removing fields based on data is awkward; you end up creating a parallel data structure.
  3. Type safetyf.value is any. No compile-time check that fields are present or correctly typed.
  4. Forms in tests — template-driven forms require change-detection cycles to settle; reactive and signal forms are testable synchronously.

If you hit any of these, switch to signal forms (preferred) or reactive forms.

Built-in validator directives #

Applied as HTML attributes on <input>-like elements:

Directive Maps to Notes
required Validators.required Boolean attribute
email Validators.email Boolean attribute
minlength="N" Validators.minLength(N) Numeric
maxlength="N" Validators.maxLength(N) Numeric
pattern="regex" Validators.pattern(...) Anchored at start and end
min="N" Validators.min(N) For number inputs
max="N" Validators.max(N) For number inputs

No more, no less. Anything else requires writing a custom validator directive (next section).

Custom validator directive #

The boilerplate for a single custom check:

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

@Directive({
  selector: '[appAsciiOnly]',
  providers: [{ provide: NG_VALIDATORS, useExisting: AsciiOnlyDirective, multi: true }],
})
export class AsciiOnlyDirective implements Validator {
  validate(c: AbstractControl): { ascii: boolean } | null {
    return /^[\x00-\x7f]*$/.test(c.value) ? null : { ascii: true };
  }
}

Usage in the template:

<input name="username" [(ngModel)]="username" required appAsciiOnly />
@if (f.controls['username']?.errors?.['ascii']) { <p>ASCII only</p> }

For a one-off custom validator, the directive boilerplate alone is the reason to switch to reactive or signal forms.

Reading form state from the template variable #

#f="ngForm" exposes:

Property Type What it tells you
f.value any Object of all control values
f.valid / f.invalid boolean Aggregate validity
f.dirty / f.pristine boolean Has user changed anything
f.touched / f.untouched boolean Has user blurred any field
f.submitted boolean True after ngSubmit fires
f.controls['name'] FormControl? Per-field state (errors, value, status)
f.reset() method Reset all fields to initial

For per-field state inside the template, the cleanest pattern is to grab the control with #ref="ngModel":

<input name="email" [(ngModel)]="email" required email #emailCtrl="ngModel" />
@if (emailCtrl.touched && emailCtrl.errors?.['required']) {
  <p>Email required</p>
}

The #emailCtrl="ngModel" exposes the underlying NgModel directive — same .errors, .touched, .dirty API as f.controls['email'] but cleaner to read.

Cross-field validation #

Template-driven forms support form-level directives the same way input-level directives work — bind a validator to the <form> element:

@Directive({
  selector: '[appPasswordsMatch]',
  providers: [{ provide: NG_VALIDATORS, useExisting: PasswordsMatchDirective, multi: true }],
})
export class PasswordsMatchDirective implements Validator {
  validate(g: AbstractControl): { mismatch: boolean } | null {
    const pw = g.get('password')?.value;
    const cf = g.get('confirm')?.value;
    return pw === cf ? null : { mismatch: true };
  }
}

Usage:

<form #f="ngForm" appPasswordsMatch (ngSubmit)="submit()">
  <input name="password" [(ngModel)]="pw" type="password" />
  <input name="confirm" [(ngModel)]="cf" type="password" />
  @if (f.errors?.['mismatch']) { <p>Passwords don't match</p> }
</form>

A real-world filter form #

A realistic case where template-driven shines — a quick filter UI in an internal dashboard:

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

@Component({
  selector: 'app-filter-bar',
  imports: [FormsModule],
  template: `
    <input name="search" [(ngModel)]="search" placeholder="Search..." />
    <select name="status" [(ngModel)]="status">
      <option value="">All statuses</option>
      <option value="active">Active</option>
      <option value="archived">Archived</option>
    </select>
    <input type="date" name="after" [(ngModel)]="after" />
    <button (click)="clear()">Clear</button>
  `,
})
export class FilterBarComponent {
  search = '';
  status: 'active' | 'archived' | '' = '';
  after = '';

  clear() { this.search = ''; this.status = ''; this.after = ''; }
}

Three fields, no validation, no submit handler — just direct two-way bindings. Reactive forms would be overkill here; template-driven is the right shape.

NgModel without a form #

[(ngModel)] works on a single input outside any <form>:

<input [(ngModel)]="search" placeholder="Search..." />

This is the cleanest "two-way bind to a primitive" syntax in Angular. For a single search box in a header, it beats both reactive forms (overkill) and signal-based wiring (more verbose).

In 2026 the recommended replacement is model() (lesson 3.3) for component-to-component binding — but [(ngModel)] on a native input is still the shortest path to writable input state.

Common gotchas #

Symptom Cause Fix
No value accessor for form control [(ngModel)] on an element Angular doesn't know how to bind Use <input>, <select>, <textarea>, or implement ControlValueAccessor
Form has no control with name X Missing name="" attribute on the input Template-driven forms register controls by name
Validators don't run Forgot the FormsModule import Add to the component's imports: [] array
f.value is missing fields Field was disabled or hidden via *ngIf Disabled controls are excluded; conditionally-rendered inputs aren't registered
Custom validator never fires Forgot to add it to NG_VALIDATORS providers The boilerplate is required

What's next #

Lesson 4.4 dives into validation patterns — sync, async, cross-field — across all three forms eras with consistent shape. Lesson 4.5 covers file uploads with progress. Lesson 4.6 closes Module 4 with typed reactive forms.

Try it yourself #

A tiny template-driven contact form:

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

@Component({
  selector: 'app-contact',
  imports: [FormsModule],
  template: `
    <form #f="ngForm" (ngSubmit)="submit(f.value)">
      <label>Name <input name="name" [(ngModel)]="name" required minlength="2" #nm="ngModel" /></label>
      @if (nm.touched && nm.invalid) { <p>Name (2+ chars) required</p> }

      <label>Message <textarea name="msg" [(ngModel)]="msg" required></textarea></label>

      <button type="submit" [disabled]="f.invalid">Send</button>
    </form>
  `,
})
export class ContactComponent {
  name = '';
  msg = '';
  submit(value: any) { console.log('Send:', value); }
}

Three lines of class code, two [(ngModel)] bindings, one form-wide validity check. Shorter than the equivalent reactive form, sufficient for this scope.

YouWhen is template-driven NEVER the right call?
YouUser-facing checkout/signup/payment forms, anything with custom validation rules beyond required/min/max, dynamic forms whose shape comes from data, and anything that needs to be tested. For those, signal forms (preferred) or reactive forms give you type safety, testability, and a sane validation API. Template-driven is for the 10-line filter bar in an internal tool, not the multi-step billing form.

Lesson 4.4 picks up validation patterns in depth across all three forms eras.

Up next in Angular

More from this topic

View all Angular articles →

Enjoyed this article?

Get new Angular tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email stays private. Required fields are marked *

Leave a Comment

Your email stays private. Required fields are marked *