Angular provides Signal Forms, reactive forms, and template-driven forms. Compare their state models, validation styles, and testing tradeoffs, then choose the approach that fits a new signal-based screen or an established application.
Angular offers three form models. Reactive forms define an explicit control tree in TypeScript. Template-driven forms let directives build that tree from template markup. Signal Forms build a typed field tree around a writable signal model.
Choose by ownership and complexity. Reactive forms fit established applications, dynamic control trees, and observable workflows. Template-driven forms suit small forms with simple validation. Signal Forms fit new signal-first features that benefit from typed field state and schema validation.
| Model | Source of truth | Good fit |
|---|---|---|
| Reactive | FormControl and FormGroup in TypeScript | Complex or dynamic forms and existing reactive-form codebases |
| Template-driven | Directives such as ngModel in HTML | Small forms with straightforward fields |
| Signal Forms | Writable model signal plus field schema | Typed, signal-first forms |
Reactive forms make the complete form model available in TypeScript. Import ReactiveFormsModule in the standalone component, create non-nullable controls when null is not a valid domain value, and submit with getRawValue after checking validity.
Use formControlName inside a parent formGroup. Control status includes valid, invalid, pending, disabled, pristine, dirty, touched, and untouched; use those states to decide when feedback should appear.
ng generate component reactive-form
import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
@Component({
selector: 'app-profile-form',
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="profile" (ngSubmit)="save()">
<label for="email">Email</label>
<input id="email" type="email" formControlName="email" />
@if (email.invalid && email.touched) {
<p role="alert">Enter a valid email address.</p>
}
<button type="submit">Save</button>
</form>
`
})
export class ProfileFormComponent {
private fb = inject(FormBuilder).nonNullable;
profile = this.fb.group({
email: ['', [Validators.required, Validators.email]]
});
get email() { return this.profile.controls.email; }
save(): void {
if (this.profile.invalid) {
this.profile.markAllAsTouched();
return;
}
console.log(this.profile.getRawValue());
}
}
The nonNullable builder keeps email typed as string. The submit handler marks hidden errors as touched, exits on invalid input, and only then reads the form value.
Use FormArray when the number of controls is data-driven, such as order lines, phone numbers, or survey answers. The array owns indexed controls; each row can be a FormControl or a typed FormGroup.
Use a stable domain identifier to track rendered rows when available. Removing a row changes control indexes, so do not use the current index as a persistent backend identity.
private createLine() {
return this.fb.group({
productId: ['', Validators.required],
quantity: [1, [Validators.required, Validators.min(1)]]
});
}
readonly order = this.fb.group({
customerId: ['', Validators.required],
lines: this.fb.array([this.createLine()])
});
get lines() {
return this.order.controls.lines;
}
addLine(): void {
this.lines.push(this.createLine());
}
removeLine(index: number): void {
this.lines.removeAt(index);
}
On submit, reject invalid or pending state, mark controls for feedback, map the form value to the API command, and prevent duplicate submissions while the request is active. The backend must validate the same business and security rules.
Use setValue when the complete shape is required and patchValue for a deliberate partial update. reset changes value and interaction state; pass an explicit value when non-nullable defaults or persisted identifiers must be restored.
save(): void {
if (this.order.invalid || this.order.pending || this.saving()) {
this.order.markAllAsTouched();
return;
}
this.saving.set(true);
const command = this.order.getRawValue();
this.orders.create(command).pipe(
finalize(() => this.saving.set(false))
).subscribe({
next: saved => {
this.lines.clear();
this.lines.push(this.createLine());
this.order.reset({ customerId: saved.customerId });
},
error: error => this.applyServerErrors(error)
});
}
Template-driven forms use FormsModule, ngForm, ngModel, and HTML-like validation attributes. Every control inside a form needs a name so Angular can register it. Export ngModel to inspect one field without repeatedly looking it up through the form.
This style keeps small forms concise, but business logic spread across template directives becomes harder to test and refactor as conditional fields and cross-field rules grow.
ng generate component template-form
import { Component } from '@angular/core';
import { FormsModule, NgForm } from '@angular/forms';
@Component({
selector: 'app-contact-form',
imports: [FormsModule],
template: `
<form #contact="ngForm" (ngSubmit)="send(contact)">
<label for="email">Email</label>
<input id="email" name="email" type="email"
[(ngModel)]="model.email" #email="ngModel" required email />
@if (email.invalid && email.touched) {
<p role="alert">Enter a valid email address.</p>
}
<button type="submit">Send</button>
</form>
`
})
export class ContactFormComponent {
model = { email: '' };
send(form: NgForm): void {
if (form.invalid) return;
console.log(this.model);
}
}
name registers the control, ngModel creates two-way binding, and #email="ngModel" exposes field state for focused feedback.
Signal Forms are stable in Angular 22. They build a typed field tree from a writable signal model, bind controls with FormField, and define validation through a schema. They are a strong fit for new signal-based applications; reactive forms remain a reliable choice for established codebases and complex dynamic form workflows.
import { Component, signal } from '@angular/core';
import { Field, form, required, email } from '@angular/forms/signals';
@Component({
selector: 'app-login',
imports: [Field],
template: `
<label for="email">Email</label>
<input id="email" type="email" [field]="loginForm.email" />
@for (error of loginForm.email().errors(); track error.kind) {
<p role="alert">{{ error.message }}</p>
}
`
})
export class LoginComponent {
model = signal({ email: '' });
loginForm = form(this.model, path => {
required(path.email, { message: 'Email is required' });
email(path.email, { message: 'Enter a valid email' });
});
}
The writable signal owns submitted data, form creates a typed field tree, and Field synchronizes the input with field state and validation.
Reactive and template-driven forms use different directive packages. A component using [formGroup] and formControlName must import ReactiveFormsModule; one using ngModel or ngForm must import FormsModule.
Most likely, the input has no name. NgForm uses that name as the key in its controls collection and submitted value.
Yes. Signal Forms are stable starting in Angular 22. Choose them for new signal-based forms when their field-tree and schema model fit the screen; keep reactive forms when migration cost or an established control-based architecture makes a rewrite unnecessary.
Explore 500+ free tutorials across 20+ languages and frameworks.