Skip to main content

Angular Forms

Angular has built-in form features that don't exist in React, offering two approaches: reactive forms and template-driven forms.

1. Template-Driven Forms​

Template-driven forms are built with directives in the HTML template (such as ngModel). Angular generates the form model (FormGroup / FormControl) from the template automatically.

Using FormsModule​

In a standalone component, add FormsModule to imports.

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

@Component({
selector: 'app-login',
standalone: true,
imports: [FormsModule],
templateUrl: './login.component.html',
})
export class LoginComponent {
model = { email: '', password: '' };

onSubmit(): void {
console.log('submit', this.model);
}
}

Two-Way Binding with ngModel​

[(ngModel)] binds "property to input" and "input to property" at the same time. The name attribute is required so that ngModel can register the control.

<form #f="ngForm" (ngSubmit)="onSubmit()" novalidate>
<label>
Email
<input
type="email"
name="email"
[(ngModel)]="model.email"
required
email
#email="ngModel"
/>
</label>
@if (email.invalid && (email.dirty || email.touched)) {
<p class="error">Please enter a valid email address.</p>
}

<label>
Password
<input
type="password"
name="password"
[(ngModel)]="model.password"
required
minlength="8"
#password="ngModel"
/>
</label>
@if (password.errors?.['minlength']) {
<p class="error">Enter at least 8 characters.</p>
}

<button type="submit" [disabled]="f.invalid">Log in</button>
</form>

Template Validation​

Standard HTML attributes such as required, minlength, maxlength, pattern, and email work as validators as-is. By exporting NgModel into a template reference variable like #email="ngModel", you can access state such as invalid, dirty, touched, and errors.

StateMeaning
valid / invalidWhether validation passes
touched / untouchedWhether the control has lost focus
dirty / pristineWhether the value has changed

Comparison with React Controlled/Uncontrolled Components​

AspectAngular template-drivenReact
Two-way binding[(ngModel)] provided out of the boxHand-written value + onChange (controlled component)
Where state livesAngular holds a FormControl internallyuseState (controlled) or the DOM itself (uncontrolled + ref)
ValidationHTML attributes + directivesCustom code or a library

It helps to think of ngModel as syntactic sugar that hides the value / onChange boilerplate of a controlled component.

2. Reactive Forms​

Reactive forms define the form model explicitly in TypeScript and bind it to the template. Changes in value and status can be subscribed to as Observables (valueChanges / statusChanges).

Using ReactiveFormsModule​

Add ReactiveFormsModule to imports.

import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';

@Component({
selector: 'app-profile',
standalone: true,
imports: [ReactiveFormsModule],
templateUrl: './profile.component.html',
})
export class ProfileComponent {
private readonly fb = inject(FormBuilder);

form = this.fb.nonNullable.group({
name: ['', [Validators.required, Validators.maxLength(50)]],
email: ['', [Validators.required, Validators.email]],
address: this.fb.nonNullable.group({
zip: [''],
city: [''],
}),
});

onSubmit(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
// Typed: { name: string; email: string; address: { zip: string; city: string } }
console.log(this.form.getRawValue());
}
}

FormGroup, FormControl, FormArray​

ClassRole
FormControlA single input value and its state (valid, dirty, etc.)
FormGroupA set of named controls; corresponds to an object
FormArrayA variable-length list of controls; corresponds to an array
FormBuilderA helper that creates the above concisely

Since Angular 14, forms are typed. With nonNullable, reset() returns to the initial value rather than null, giving you type-safe values.

The formControlName Directive​

Bind a group with [formGroup] and each control with formControlName. Use formGroupName for nested groups.

<form [formGroup]="form" (ngSubmit)="onSubmit()">
<input type="text" formControlName="name" />
<input type="email" formControlName="email" />

<div formGroupName="address">
<input type="text" formControlName="zip" />
<input type="text" formControlName="city" />
</div>

<button type="submit" [disabled]="form.invalid">Save</button>
</form>

Value changes can be handled as a stream.

import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { debounceTime } from 'rxjs';

constructor() {
this.form.controls.email.valueChanges
.pipe(debounceTime(300), takeUntilDestroyed())
.subscribe((v) => console.log('email changed', v));
}

Conceptual Comparison with React Hook Form / Formik​

ConceptAngular reactive formsReact Hook FormFormik
Form definitionFormGroup / FormBuilderuseForm()useFormik() / <Formik>
Field registrationformControlNameregister('name')getFieldProps / <Field>
ValidationValidators functionsrules / resolver (Zod, etc.)validate / Yup
Statevalid / dirty / touchedformStateerrors / touched
Subscribing to valuesvalueChanges (Observable)watch()values
Dynamic arraysFormArrayuseFieldArrayFieldArray

This is close to React Hook Form's philosophy of letting a library own the form state; in Angular, that machinery is built into the framework from the start.

3. Validation​

Built-in Validators​

The Validators class provides the standard validators.

ValidatorPurpose
Validators.requiredRequired
Validators.emailEmail format
Validators.min(n) / max(n)Numeric range
Validators.minLength(n) / maxLength(n)Length
Validators.pattern(regex)Regular expression
const password = new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.minLength(8)],
});

Creating Custom Validators​

A validator is a function that takes an AbstractControl and returns a ValidationErrors object on error, or null when there is no problem.

import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';

// Reject input containing a forbidden word (factory function)
export function forbiddenWord(word: string): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const value = String(control.value ?? '');
return value.includes(word)
? { forbiddenWord: { word } }
: null;
};
}

// Cross-field validator (attached to a FormGroup)
export const passwordsMatch: ValidatorFn = (group) => {
const pw = group.get('password')?.value;
const confirm = group.get('confirm')?.value;
return pw === confirm ? null : { passwordsMismatch: true };
};
form = this.fb.nonNullable.group(
{
username: ['', [Validators.required, forbiddenWord('admin')]],
password: ['', [Validators.required, Validators.minLength(8)]],
confirm: [''],
},
{ validators: passwordsMatch },
);

Async Validators​

Use async validators for checks that require a server call, such as username uniqueness. They return an Observable or a Promise. They run only after all synchronous validators pass, and the status is PENDING while running.

import { AsyncValidatorFn } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, map, of, switchMap, timer } from 'rxjs';

export function uniqueUsername(): AsyncValidatorFn {
const http = inject(HttpClient); // must be called in an injection context
return (control) =>
timer(300).pipe( // acts as a debounce
switchMap(() =>
http.get<{ exists: boolean }>('/api/users/exists', {
params: { name: control.value },
}),
),
map((res) => (res.exists ? { usernameTaken: true } : null)),
catchError(() => of(null)),
);
}
username: ['', {
validators: [Validators.required],
asyncValidators: [uniqueUsername()],
updateOn: 'blur', // don't query on every keystroke
}],

A factory that uses inject() must be called within an injection context, such as a component field initializer.

Error Message Display Patterns​

It is common to show errors only once the control is touched or dirty. The new @if control flow keeps this concise.

<input type="email" formControlName="email" />
@let email = form.controls.email;
@if (email.invalid && (email.touched || email.dirty)) {
<ul class="errors">
@if (email.hasError('required')) {
<li>Email is required.</li>
}
@if (email.hasError('email')) {
<li>The email format is invalid.</li>
}
</ul>
}
@if (email.pending) {
<p>Checking...</p>
}

When error message logic grows, extract it into a reusable FormErrorComponent that holds a map of error keys to messages, so each form template doesn't bloat.

4. Dynamic Form Construction​

Dynamic Fields with FormArray​

Fields that grow or shrink through user action, such as phone numbers or line items, are represented with FormArray.

import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';

@Component({
selector: 'app-order-form',
standalone: true,
imports: [ReactiveFormsModule],
templateUrl: './order-form.component.html',
})
export class OrderFormComponent {
private readonly fb = inject(FormBuilder);

form = this.fb.nonNullable.group({
items: this.fb.array([this.createItem()]),
});

get items() {
return this.form.controls.items;
}

private createItem() {
return this.fb.nonNullable.group({
product: ['', Validators.required],
quantity: [1, [Validators.required, Validators.min(1)]],
});
}

addItem(): void {
this.items.push(this.createItem());
}

removeItem(index: number): void {
this.items.removeAt(index);
}
}
<form [formGroup]="form">
<div formArrayName="items">
@for (item of items.controls; track $index; let i = $index) {
<div [formGroupName]="i">
<input formControlName="product" placeholder="Product" />
<input type="number" formControlName="quantity" />
<button type="button" (click)="removeItem(i)">Remove</button>
</div>
}
</div>
<button type="button" (click)="addItem()">Add row</button>
</form>

This plays the same role as useFieldArray in React Hook Form.

Conditional Form Display​

Switch field visibility and validation depending on another control's value. Use @if for display and setValidators to enable validation.

constructor() {
this.form.controls.contactType.valueChanges
.pipe(takeUntilDestroyed())
.subscribe((type) => {
const phone = this.form.controls.phone;
if (type === 'phone') {
phone.setValidators([Validators.required]);
} else {
phone.clearValidators();
phone.reset();
}
phone.updateValueAndValidity();
});
}
<select formControlName="contactType">
<option value="email">Email</option>
<option value="phone">Phone</option>
</select>

@if (form.controls.contactType.value === 'phone') {
<input type="tel" formControlName="phone" />
}

Always call updateValueAndValidity() after setValidators to trigger re-evaluation.

5. Which Form Approach to Choose​

AspectTemplate-drivenReactive
Where definedTemplateComponent class
Data flowAsynchronous (via ngModel)Synchronous, explicit
Type safetyWeakStrong with typed forms
Dynamic fieldsDifficultEasy with FormArray
Complex validationWeakStrong: custom, async, cross-field
Unit testingNeeds DOM and templateVerifiable with the class alone
Code volume (small forms)LessSlightly more
  • Template-driven: suited to forms with few fields and simple validation, such as a login or search box.
  • Reactive: suited to complex forms with many fields, dynamic add/remove, or dependencies between fields, and when you want to unit test validation logic.

The recommended choice in practice is reactive forms. They excel in type safety, testability, and flexibility for dynamic construction, and keep code readable as the form grows. Mixing approaches within one project raises the learning cost, so it is safest to standardize on reactive forms and make only very simple forms an exception.