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.
| State | Meaning |
|---|---|
valid / invalid | Whether validation passes |
touched / untouched | Whether the control has lost focus |
dirty / pristine | Whether the value has changed |
Comparison with React Controlled/Uncontrolled Components
| Aspect | Angular template-driven | React |
|---|---|---|
| Two-way binding | [(ngModel)] provided out of the box | Hand-written value + onChange (controlled component) |
| Where state lives | Angular holds a FormControl internally | useState (controlled) or the DOM itself (uncontrolled + ref) |
| Validation | HTML attributes + directives | Custom 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
| Class | Role |
|---|---|
FormControl | A single input value and its state (valid, dirty, etc.) |
FormGroup | A set of named controls; corresponds to an object |
FormArray | A variable-length list of controls; corresponds to an array |
FormBuilder | A 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
| Concept | Angular reactive forms | React Hook Form | Formik |
|---|---|---|---|
| Form definition | FormGroup / FormBuilder | useForm() | useFormik() / <Formik> |
| Field registration | formControlName | register('name') | getFieldProps / <Field> |
| Validation | Validators functions | rules / resolver (Zod, etc.) | validate / Yup |
| State | valid / dirty / touched | formState | errors / touched |
| Subscribing to values | valueChanges (Observable) | watch() | values |
| Dynamic arrays | FormArray | useFieldArray | FieldArray |
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.
| Validator | Purpose |
|---|---|
Validators.required | Required |
Validators.email | Email 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
| Aspect | Template-driven | Reactive |
|---|---|---|
| Where defined | Template | Component class |
| Data flow | Asynchronous (via ngModel) | Synchronous, explicit |
| Type safety | Weak | Strong with typed forms |
| Dynamic fields | Difficult | Easy with FormArray |
| Complex validation | Weak | Strong: custom, async, cross-field |
| Unit testing | Needs DOM and template | Verifiable with the class alone |
| Code volume (small forms) | Less | Slightly 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.