NgModel and FormsModule — Wiring Up Template-Driven Forms

Template-driven forms use Angular directives in the template to create form controls and manage form state. You bind form inputs with [(ngModel)] and add validation with standard HTML attributes like required and minlength — Angular enhances these with reactive form state (valid, invalid, touched, dirty). The form’s overall validity is accessible via #form='ngForm'. Template-driven forms are the right choice for simple forms where the logic fits cleanly in the template without complex cross-field validation or programmatic control.

Template-Driven Form Setup

import { Component, signal, inject } from '@angular/core';
import { FormsModule, NgForm } from '@angular/forms';
import { Router } from '@angular/router';
import { AuthApiService } from '@core/services/auth-api.service';

@Component({
  selector:   'app-login',
  standalone:  true,
  imports:    [FormsModule],   // required for ngModel and NgForm
  template: `
    <!-- #loginForm='ngForm' exports the NgForm directive instance ──────── -->
    <form #loginForm='ngForm' (ngSubmit)="onSubmit(loginForm)"
          class="login-form" novalidate>

      <!-- name attribute required for NgModel to register the control ───── -->
      <div class="field">
        <label for="email">Email</label>
        <input id="email"
               type="email"
               name="email"
               [(ngModel)]="credentials.email"
               required
               email
               #emailControl="ngModel">  <!-- export ngModel for this field ─ -->

        <!-- Show error only after user has touched the field ──────────── -->
        @if (emailControl.invalid && emailControl.touched) {
          <span class="error">
            @if (emailControl.errors?.['required']) { Email is required. }
            @if (emailControl.errors?.['email'])    { Enter a valid email address. }
          </span>
        }
      </div>

      <div class="field">
        <label for="password">Password</label>
        <input id="password"
               type="password"
               name="password"
               [(ngModel)]="credentials.password"
               required
               minlength="8"
               #passwordControl="ngModel">

        @if (passwordControl.invalid && passwordControl.touched) {
          <span class="error">
            @if (passwordControl.errors?.['required'])  { Password is required. }
            @if (passwordControl.errors?.['minlength']) {
              Password must be at least
              {{ passwordControl.errors?.['minlength'].requiredLength }} characters.
            }
          </span>
        }
      </div>

      <!-- Server-level error (after API call) ─────────────────────────── -->
      @if (serverError()) {
        <div class="server-error">{{ serverError() }}</div>
      }

      <!-- Disable button while form invalid or submitting ─────────────── -->
      <button type="submit"
              [disabled]="loginForm.invalid || isSaving()">
        {{ isSaving() ? 'Signing in...' : 'Sign In' }}
      </button>
    </form>
  `,
})
export class LoginComponent {
  private authApi = inject(AuthApiService);
  private router  = inject(Router);

  // Two-way bound model — ngModel reads/writes here
  credentials = { email: '', password: '' };

  isSaving    = signal(false);
  serverError = signal('');

  onSubmit(form: NgForm): void {
    if (form.invalid) return;    // extra safety check

    this.isSaving.set(true);
    this.serverError.set('');

    this.authApi.login(this.credentials).subscribe({
      next:  () => this.router.navigate(['/posts']),
      error: err => {
        this.isSaving.set(false);
        this.serverError.set(err.status === 401
          ? 'Invalid email or password.'
          : 'Login failed. Please try again.');
      },
    });
  }
}
Note: The name attribute is required on every form control that uses [(ngModel)] inside a <form> element. Without name, Angular cannot register the control with the parent NgForm — the control exists in isolation and the form’s validity does not include it. The name value becomes the key in form.value (e.g., form.value.email). You can also use [ngModelOptions]="{standalone: true}" to explicitly opt out of form registration when you want an ngModel that does not participate in the form.
Tip: Use the touched state rather than dirty to trigger error display. touched becomes true when the user focuses then leaves a field (blur event) — errors appear after the user has finished with the field. dirty becomes true on the first keystroke — errors would appear as soon as typing starts, before the user has had a chance to complete their input. The touched && invalid pattern gives the best user experience: errors appear precisely when the user has interacted and not provided valid input.
Warning: Add novalidate to the <form> element to disable native browser validation. Without it, the browser shows its own native validation popups (tooltip bubbles) alongside Angular’s template validation messages, resulting in duplicate error displays. Angular’s validation directives handle all validation feedback — the browser’s native UI is redundant and inconsistent across browsers. Always use novalidate on Angular forms.

Form State Reference

State CSS Class Meaning
valid ng-valid All validators pass
invalid ng-invalid At least one validator fails
pristine ng-pristine Value unchanged since creation
dirty ng-dirty Value changed at least once
touched ng-touched Focus then blur at least once
untouched ng-untouched Never been blurred

Common Mistakes

Mistake 1 — Missing name attribute on ngModel controls (form validity not tracked)

❌ Wrong — <input [(ngModel)]="email"> without name="email"; not registered in NgForm.

✅ Correct — always add name="fieldName" to every ngModel inside a form element.

Mistake 2 — Showing errors based on dirty instead of touched (errors appear while typing)

❌ Wrong — *ngIf="control.invalid && control.dirty"; error appears on the first keystroke.

✅ Correct — use control.invalid && control.touched; errors appear after the field loses focus.

🧠 Test Yourself

A form has #loginForm='ngForm' and a submit button with [disabled]="loginForm.invalid". The form has one field: a required email. The page first loads. Is the button disabled?