Forms
|
This section documents modern, standalone Angular — signals, the built-in This content was generated with the assistance of AI and should be verified against angular.dev before being relied on in production. Angular ships a major release roughly every six months and its APIs continue to evolve: the examples here target the current major release; where a consulted source disagrees with the current documentation, the documentation wins and the difference is noted. This section’s bibliography lists the reference material consulted while preparing these pages. |
Angular ships two ways to build forms. Template-driven forms keep the model in the template and suit small, simple forms; reactive forms declare an explicit, strongly typed model in the component class and scale to complex, dynamic, and heavily validated forms. See Forms.
Template-driven forms
Import FormsModule. ngModel binds a field to a property, ngForm is applied automatically to every
<form> and tracks the aggregate state, and ngModelGroup nests a set of fields. Every control needs a
name.
import { Component } from '@angular/core';
import { FormsModule, NgForm } from '@angular/forms';
@Component({
selector: 'app-signup',
imports: [FormsModule],
templateUrl: './signup.html',
})
export class Signup {
model = { email: '', address: { city: '', zip: '' } };
submit(form: NgForm): void {
if (form.valid) {
console.log(this.model);
}
}
}
<form #form="ngForm" (ngSubmit)="submit(form)">
<input name="email" [(ngModel)]="model.email" required email #email="ngModel" />
@if (email.invalid && email.touched) {
<p class="error">A valid email is required.</p>
}
<fieldset ngModelGroup="address">
<input name="city" [(ngModel)]="model.address.city" required />
<input name="zip" [(ngModel)]="model.address.zip" pattern="\d{5}" />
</fieldset>
<button type="submit" [disabled]="form.invalid">Sign up</button>
</form>
[(ngModel)] is the "banana-in-a-box" shorthand for [ngModel] plus (ngModelChange). Export a control as a
local template variable (#email="ngModel") to read its valid / touched / errors in the template. See
Template-driven forms.
Reactive forms
Import ReactiveFormsModule. Build the model from FormControl, FormGroup, and FormArray, then bind it
with [formGroup], formControlName, formGroupName, and formArrayName.
import { Component } from '@angular/core';
import {
FormControl, FormGroup, FormArray,
ReactiveFormsModule, Validators,
} from '@angular/forms';
@Component({
selector: 'app-profile',
imports: [ReactiveFormsModule],
templateUrl: './profile.html',
})
export class Profile {
readonly form = new FormGroup({
name: new FormControl('', { nonNullable: true, validators: [Validators.required] }),
email: new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.email],
}),
address: new FormGroup({
street: new FormControl('', { nonNullable: true }),
city: new FormControl('', { nonNullable: true }),
}),
tags: new FormArray<FormControl<string>>([]),
});
addTag(): void {
this.form.controls.tags.push(new FormControl('', { nonNullable: true }));
}
save(): void {
if (this.form.valid) {
console.log(this.form.getRawValue());
}
}
}
<form [formGroup]="form" (ngSubmit)="save()">
<input formControlName="name" />
<fieldset formGroupName="address">
<input formControlName="street" />
<input formControlName="city" />
</fieldset>
<div formArrayName="tags">
@for (tag of form.controls.tags.controls; track $index) {
<input [formControlName]="$index" />
}
</div>
<button type="button" (click)="addTag()">Add tag</button>
</form>
See Reactive forms.
FormBuilder
FormBuilder is a terser factory. Its nonNullable variant applies \{ nonNullable: true } to every control
in the group.
import { inject } from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms';
private readonly fb = inject(FormBuilder);
readonly form = this.fb.nonNullable.group({
name: ['', Validators.required],
email: ['', [Validators.required, Validators.email]],
address: this.fb.nonNullable.group({
street: [''],
city: [''],
}),
tags: this.fb.array<string>([]),
});
Strictly typed forms
Since Angular 14 reactive forms are typed by default. form.value is a partial view of the model
(disabled controls are omitted), while form.getRawValue() returns the complete type. form.controls.name is
a FormControl<string | null> unless you pass \{ nonNullable: true }, which changes the type to string and
makes reset() restore the initial value instead of null. Reach for UntypedFormGroup /
UntypedFormControl only while migrating an old codebase. See
Typed forms.
Reading and reacting to state
Every AbstractControl exposes a snapshot and a matching stream:
| Member | Meaning |
|---|---|
|
current value / |
|
|
|
whether the user has changed the value |
|
whether the control has been blurred |
|
the validation-errors map, or |
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { debounceTime } from 'rxjs';
constructor() {
this.form.controls.email.valueChanges
.pipe(debounceTime(300), takeUntilDestroyed())
.subscribe((email) => this.lookup(email));
}
// Or expose the status as a signal:
readonly status = toSignal(this.form.statusChanges, { initialValue: this.form.status });
Write values with setValue (needs the complete shape), patchValue (accepts a partial), and reset
(clears to the initial value or null and restores pristine / untouched). Change a group’s shape at
runtime with addControl / removeControl / setControl, and a FormArray with push / insert /
removeAt. See AbstractControl.
Validation
Built-in Validators: required, requiredTrue, min, max,
minLength, maxLength, pattern, email, and Validators.compose. Attach them at construction or later
with setValidators / addValidators, followed by updateValueAndValidity().
Custom synchronous validator
A ValidatorFn receives a control and returns an errors object or null.
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
export function forbiddenName(pattern: RegExp): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null =>
pattern.test(control.value) ? { forbiddenName: { value: control.value } } : null;
}
// name: new FormControl('', [Validators.required, forbiddenName(/admin/i)])
Cross-field validation
Put the validator on the group so it can compare siblings.
const passwordsMatch: ValidatorFn = (group: AbstractControl): ValidationErrors | null => {
const pw = group.get('password')?.value;
const confirm = group.get('confirm')?.value;
return pw === confirm ? null : { passwordsMismatch: true };
};
readonly form = new FormGroup({
password: new FormControl('', { nonNullable: true }),
confirm: new FormControl('', { nonNullable: true }),
}, { validators: passwordsMatch });
Asynchronous validator
An AsyncValidatorFn returns a Promise or Observable that completes with the errors object or null; the
control’s status stays PENDING until it settles.
import { AsyncValidatorFn } from '@angular/forms';
import { map, first } from 'rxjs';
export function uniqueEmail(api: UserApi): AsyncValidatorFn {
return (control) =>
api.exists(control.value).pipe(
map((taken) => (taken ? { emailTaken: true } : null)),
first(),
);
}
// email: new FormControl('', {
// validators: [Validators.required, Validators.email],
// asyncValidators: [uniqueEmail(inject(UserApi))],
// })
CSS state classes
Angular mirrors control state onto the host element as classes — ng-valid / ng-invalid,
ng-pristine / ng-dirty, ng-touched / ng-untouched, and ng-pending — so error styling needs no
template logic:
input.ng-invalid.ng-touched {
border-color: crimson;
}
See Form validation.
Custom form controls
Implement ControlValueAccessor so a component participates in formControlName / ngModel like a native
input, and register it through the NG_VALUE_ACCESSOR multi-provider.
import { Component, forwardRef, signal } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
@Component({
selector: 'app-star-rating',
template: `
@for (star of stars; track star) {
<button type="button" (click)="pick(star)" [disabled]="disabled()">
{{ star <= value() ? '★' : '☆' }}
</button>
}
`,
providers: [
{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => StarRating), multi: true },
],
})
export class StarRating implements ControlValueAccessor {
readonly stars = [1, 2, 3, 4, 5];
readonly value = signal(0);
readonly disabled = signal(false);
private onChange: (v: number) => void = () => {};
private onTouched: () => void = () => {};
writeValue(v: number): void { this.value.set(v ?? 0); }
registerOnChange(fn: (v: number) => void): void { this.onChange = fn; }
registerOnTouched(fn: () => void): void { this.onTouched = fn; }
setDisabledState(isDisabled: boolean): void { this.disabled.set(isDisabled); }
pick(v: number): void {
this.value.set(v);
this.onChange(v);
this.onTouched();
}
}
See ControlValueAccessor.
Dynamic forms
Build controls from data — for example a field schema fetched from the server — by iterating a config array
and assembling a FormGroup at runtime, then rendering it with @for over the same config. See
Dynamic forms.
interface FieldConfig { key: string; label: string; required?: boolean; }
buildForm(config: FieldConfig[]): FormGroup {
const group: Record<string, FormControl<string>> = {};
for (const field of config) {
group[field.key] = new FormControl('', {
nonNullable: true,
validators: field.required ? [Validators.required] : [],
});
}
return new FormGroup(group);
}
Experimental: Signal Forms
Angular is developing Signal Forms (@angular/forms/signals), a signal-native API where the form model is
a signal and validation is declared against it rather than attached to control instances. It is
experimental, its surface may change, and it should not be used in production yet — track its progress at
the Signal Forms guide.
The reactive-form model tree
A FormGroup keys its children by name; a FormArray indexes them; both can nest arbitrarily. value,
status, dirty, and touched aggregate upward from the leaves to the root control. Cross-links:
Signals for toSignal bridging, HTTP Client
for submitting form data, and Error Handling for reporting failed
submissions.