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');
}
}
<!-- login.html -->
<form (ngSubmit)="onSubmit()">
<input type="email" name="email" />
<button type="submit">Log in</button>
</form>
Every
<input>inside a template-driven form must have anameattribute — Angular uses it to register the control withNgForm.
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);
}
}
<form (ngSubmit)="onSubmit()">
<input type="email" name="email" [(ngModel)]="email" />
<input type="password" name="password" [(ngModel)]="password" />
<button type="submit">Log in</button>
</form>
[(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>
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>
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>
import { NgForm } from '@angular/forms';
export class LoginComponent {
onSubmit(form: NgForm) {
if (form.valid) {
console.log(form.value); // { email: '...', password: '...' }
}
}
}
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>
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)
}
}
#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>
import { NgForm } from '@angular/forms';
export class SignUpComponent {
onSubmit(form: NgForm) {
if (form.valid) {
console.log(form.value); // { email: '...', password: '...' }
form.reset();
}
}
}
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;
}
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>
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();
}
}
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>
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
}
}
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);
});
}
}
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
}
}
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));
}
}
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);
}
}
<!-- parent template -->
<app-panel>
<h2 #panelTitle>My Panel</h2>
<p>Some content</p>
</app-panel>
The signal version with contentChild():
import { contentChild, ElementRef } from '@angular/core';
export class PanelComponent {
titleEl = contentChild<ElementRef>('panelTitle');
}
@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)