DEV Community

Atilla Baspinar
Atilla Baspinar

Posted on

Template-Driven Forms

Angular provides two approaches to forms: template-driven (covered here) and reactive. Template-driven forms use FormsModule and keep most form logic in the template via directives.


1. FormsModule and ngSubmit

Import FormsModule into the component's imports array to enable template-driven form directives.

When FormsModule is imported, Angular automatically attaches an NgForm directive to every <form> element. This directive tracks the form's state (validity, touched, dirty) and intercepts the native browser submit event.

Use (ngSubmit) instead of the native (submit)NgForm suppresses the default browser navigation and gives you the Angular-managed form object.

@Component({
  selector: 'app-login',
  templateUrl: './login.html',
  imports: [FormsModule],
})
export class LoginComponent {
  onSubmit() {
    console.log('form submitted');
  }
}
Enter fullscreen mode Exit fullscreen mode
<!-- login.html -->
<form (ngSubmit)="onSubmit()">
  <input type="email" name="email" />
  <button type="submit">Log in</button>
</form>
Enter fullscreen mode Exit fullscreen mode

Every <input> inside a template-driven form must have a name attribute — Angular uses it to register the control with NgForm.


2. Two-way binding with [(ngModel)]

ngModel (from FormsModule) binds an input's value to a component property in both directions: the DOM updates when the property changes, and the property updates when the user types.

export class LoginComponent {
  email = '';
  password = '';

  onSubmit() {
    console.log(this.email, this.password);
  }
}
Enter fullscreen mode Exit fullscreen mode
<form (ngSubmit)="onSubmit()">
  <input type="email" name="email" [(ngModel)]="email" />
  <input type="password" name="password" [(ngModel)]="password" />
  <button type="submit">Log in</button>
</form>
Enter fullscreen mode Exit fullscreen mode

[(ngModel)] is syntactic sugar for [ngModel]="email" (ngModelChange)="email = $event". The square brackets push the value in; the parentheses listen for changes coming out.


3. Template variables

A template variable is declared with a # prefix on any element or component in the template. It is similar to a ref in React — it gives you a direct handle to that element or component instance within the same template.

<input #emailInput type="email" />
<button (click)="emailInput.focus()">Focus email</button>
Enter fullscreen mode Exit fullscreen mode

emailInput is a reference to the native HTMLInputElement.

Template variables on custom components

When you place #ref on a custom component, Angular assigns the variable to the component class instance, not the underlying DOM element.

<!-- myRef is the CardComponent instance, not an HTMLElement -->
<app-card #myRef />
<button (click)="myRef.reset()">Reset card</button>
Enter fullscreen mode Exit fullscreen mode

This lets you call methods or read properties directly on the component from within the template.

Referencing the form with NgForm

When FormsModule is imported, Angular attaches an NgForm directive to every <form>. You can capture that directive instance by assigning ngForm to a template variable:

<form #loginForm="ngForm" (ngSubmit)="onSubmit(loginForm)">
  <input type="email" name="email" ngModel />
  <input type="password" name="password" ngModel />
  <button type="submit">Log in</button>
  <button type="button" (click)="loginForm.reset()">Reset</button>
</form>
Enter fullscreen mode Exit fullscreen mode
import { NgForm } from '@angular/forms';

export class LoginComponent {
  onSubmit(form: NgForm) {
    if (form.valid) {
      console.log(form.value); // { email: '...', password: '...' }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

form.value is a plain object keyed by each input's name attribute. loginForm.reset() clears all field values and resets validation state.

Without NgForm — binding fields to component member variables

If you don't need NgForm's validity tracking or form.value object, bind each field to a component property with [(ngModel)] and use a plain template variable to get the native HTMLFormElement for reset.

<form #loginForm (ngSubmit)="onSubmit()">
  <input type="email" name="email" [(ngModel)]="email" />
  <input type="password" name="password" [(ngModel)]="password" />
  <button type="submit">Log in</button>
  <button type="button" (click)="reset(loginForm)">Reset</button>
</form>
Enter fullscreen mode Exit fullscreen mode
export class LoginComponent {
  email = '';
  password = '';

  onSubmit() {
    console.log(this.email, this.password);
  }

  reset(form: HTMLFormElement) {
    this.email = '';      // [(ngModel)] propagates '' back to the input
    this.password = '';
    form.reset();         // also resets native browser state (validation highlights, autofill)
  }
}
Enter fullscreen mode Exit fullscreen mode

#loginForm without ="ngForm" is a reference to the native HTMLFormElement — the DOM element itself, not the NgForm directive. So form in reset(form: HTMLFormElement) is the HTMLFormElement, and form.reset() is the native DOM method.

The difference between the two approaches (#form="ngForm" vs plain #form) is covered in the table below. For accessing the form element from the component class instead of passing it through a method argument, see @ViewChild in Section 5.

#form="ngForm" #form (no assignment)
Type NgForm directive instance HTMLFormElement
form.value Object of all field values Not available
Validity tracking form.valid, form.dirty, etc. Not available
Reset form.reset() (Angular) form.reset() (native DOM)
When to use When you need validation state When fields are bound via [(ngModel)]

4. Form validation

When FormsModule is imported, Angular recognises standard HTML validation attributes (required, email, minlength, etc.) and tracks validity state on each control automatically.

Per-field validation with a template variable

Bind ngModel to a template variable to get the NgModel instance for that control and read its state directly in the template:

<form #signUpForm="ngForm" (ngSubmit)="onSubmit(signUpForm)">
  <input name="email" ngModel required email #emailCtrl="ngModel" />
  @if (emailCtrl.touched && emailCtrl.invalid) {
    <p class="error">Enter a valid email address.</p>
  }

  <input name="password" ngModel required minlength="8" #passwordCtrl="ngModel" />
  @if (passwordCtrl.touched && passwordCtrl.errors?.['minlength']) {
    <p class="error">Password must be at least 8 characters.</p>
  }

  <button type="submit" [disabled]="signUpForm.invalid">Submit</button>
</form>
Enter fullscreen mode Exit fullscreen mode
import { NgForm } from '@angular/forms';

export class SignUpComponent {
  onSubmit(form: NgForm) {
    if (form.valid) {
      console.log(form.value); // { email: '...', password: '...' }
      form.reset();
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Key properties available on both NgModel (per-field) and NgForm (whole form):

Property Meaning
valid / invalid Passes / fails all validators
touched / untouched Has / has not been focused and blurred
dirty / pristine Value has / has not been changed by the user
errors Object with error keys, e.g. { required: true }, { minlength: { ... } }

CSS classes added by Angular

Angular keeps these classes in sync on every form input automatically:

Class pair What it signals
ng-pristine / ng-dirty Value unchanged / changed by user
ng-untouched / ng-touched Not yet focused / has been focused
ng-valid / ng-invalid All validators pass / at least one fails

Use them in CSS to give validation feedback without extra logic in the component:

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

input.ng-touched.ng-valid {
  border-color: green;
}
Enter fullscreen mode Exit fullscreen mode

5. @ViewChild — querying a single element (decorator)

@ViewChild lets you access a template element, component, or directive from inside the component class. The result is available from ngAfterViewInit onwards — not in ngOnInit, because the view hasn't been built yet.

@ViewChild accepts:

Argument What it selects
A component or directive class The first matching instance in the view
A template variable name (string) The element or component the #variable points to
TemplateRef An <ng-template> element

Example A — querying an NgForm directive instance

When the template variable uses ="ngForm", @ViewChild typed as NgForm gives you the directive directly — no .nativeElement needed.

<form #loginForm="ngForm" (ngSubmit)="onSubmit()">
  <input type="email" name="email" ngModel />
  <button type="submit">Log in</button>
  <button type="button" (click)="resetForm()">Reset</button>
</form>
Enter fullscreen mode Exit fullscreen mode
import { ViewChild, AfterViewInit } from '@angular/core';
import { NgForm } from '@angular/forms';

export class LoginComponent implements AfterViewInit {
  @ViewChild('loginForm') form!: NgForm;

  ngAfterViewInit() {
    console.log(this.form.value); // NgForm is available here
  }

  resetForm() {
    this.form.reset();
  }
}
Enter fullscreen mode Exit fullscreen mode

Example B — querying a native HTML element

When the template variable has no ="..." assignment, @ViewChild on a plain HTML element returns an ElementRef — a wrapper around the DOM node. The actual element is at .nativeElement.

<form #loginForm (ngSubmit)="onSubmit()">
  <input type="email" name="email" [(ngModel)]="email" />
  <input type="password" name="password" [(ngModel)]="password" />
  <button type="submit">Log in</button>
  <button type="button" (click)="reset()">Reset</button>
</form>
Enter fullscreen mode Exit fullscreen mode
import { ViewChild, ElementRef, AfterViewInit } from '@angular/core';

export class LoginComponent implements AfterViewInit {
  @ViewChild('loginForm') loginFormEl!: ElementRef<HTMLFormElement>;

  email = '';
  password = '';

  onSubmit() {
    console.log(this.email, this.password);
  }

  reset() {
    this.email = '';
    this.password = '';
    this.loginFormEl.nativeElement.reset(); // native DOM reset for browser-level state
  }
}
Enter fullscreen mode Exit fullscreen mode

This is the @ViewChild equivalent of Option A from Section 3 — the difference is that the form element lives on the class rather than being passed as a method argument.

@ViewChildren — querying multiple elements

@ViewChildren selects all matching elements or directives in the view and returns a live QueryList. Useful when you have a dynamic list of child components.

import { ViewChildren, QueryList, AfterViewInit } from '@angular/core';
import { NgModel } from '@angular/forms';

@Component({
  selector: 'app-signup',
  templateUrl: './signup.html',
  imports: [FormsModule],
})
export class SignupComponent implements AfterViewInit {
  @ViewChildren(NgModel) controls!: QueryList<NgModel>;

  ngAfterViewInit() {
    this.controls.forEach(control => {
      console.log(control.name, control.valid);
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

QueryList is live — it updates when the view changes (e.g. when @if adds or removes elements). Subscribe to controls.changes to react to those updates.


6. viewChild() and viewChildren() — signal-based queries (Angular 17+)

Angular 17 introduced viewChild() and viewChildren() as function-based, signal alternatives to @ViewChild and @ViewChildren. They return signals, so the value is always up to date and composable with computed() and effect().

viewChild()

import { viewChild } from '@angular/core';
import { NgForm } from '@angular/forms';

export class LoginComponent {
  form = viewChild<NgForm>('loginForm'); // signal<NgForm | undefined>

  resetForm() {
    this.form()?.reset(); // call the signal to read its current value
  }
}
Enter fullscreen mode Exit fullscreen mode

No need to implement AfterViewInit — the signal is updated automatically when the view initializes.

viewChildren()

import { viewChildren } from '@angular/core';
import { NgModel } from '@angular/forms';

export class SignupComponent {
  controls = viewChildren(NgModel); // signal<readonly NgModel[]>

  logAll() {
    this.controls().forEach(c => console.log(c.name, c.valid));
  }
}
Enter fullscreen mode Exit fullscreen mode

viewChildren() returns a signal containing a readonly array (not a QueryList), which makes it straightforward to use in reactive contexts.


7. @ContentChild / contentChild() — querying projected content

@ContentChild (decorator) and contentChild() (signal, Angular 17+) work like their View equivalents, but they query content projected into the component via <ng-content> rather than elements defined in the component's own template. They are rarely needed in day-to-day development.

// component that receives projected content
@Component({
  selector: 'app-panel',
  template: `
    <div class="panel">
      <ng-content />
    </div>
  `,
})
export class PanelComponent implements AfterContentInit {
  @ContentChild('panelTitle') titleEl!: ElementRef;

  ngAfterContentInit() {
    console.log(this.titleEl.nativeElement.textContent);
  }
}
Enter fullscreen mode Exit fullscreen mode
<!-- parent template -->
<app-panel>
  <h2 #panelTitle>My Panel</h2>
  <p>Some content</p>
</app-panel>
Enter fullscreen mode Exit fullscreen mode

The signal version with contentChild():

import { contentChild, ElementRef } from '@angular/core';

export class PanelComponent {
  titleEl = contentChild<ElementRef>('panelTitle');
}
Enter fullscreen mode Exit fullscreen mode

@ContentChildren / contentChildren() follow the same pattern and return all matches instead of just the first.


8. Related lifecycle hooks

Hook Triggered when Use for
ngAfterViewInit Once, after the component's own template and all child views are initialized First access to @ViewChild / viewChild() results
ngAfterViewChecked After every change detection check of the component's view Rarely needed; reading DOM state after each render
ngAfterContentInit Once, after projected content (<ng-content>) is initialized First access to @ContentChild / contentChild() results
ngAfterContentChecked After every change detection check of projected content Rarely needed

With signal queries (viewChild(), contentChild()), you generally don't need to implement these hooks — the signal itself handles timing. With decorator queries (@ViewChild, @ContentChild), access the result only inside or after the corresponding After*Init hook.

Top comments (0)