Angular Reactive Forms vs Template-Driven Forms (With Examples)
Angular gives you two ways to build forms: reactive forms and template-driven forms. Both end up as the same FormControl objects under the hood, but they differ in where the form model lives, how you validate it and how easy it is to test. This guide shows the same sign-up form built both ways, then covers typed forms, custom and async validators, dynamic fields with FormArray, and a quick decision table.
All examples use standalone components (the default since Angular 17).
The short answer
| Reactive forms | Template-driven forms | |
|---|---|---|
| Form model lives in | The component class | The template (ngModel) |
| Module / import | ReactiveFormsModule | FormsModule |
| Typing | Strictly typed (FormControl<string>) | Loosely typed |
| Validation | Functions in TypeScript | Attributes and directives in HTML |
| Dynamic fields | Easy with FormArray | Awkward |
| Unit testing | Test the class, no DOM needed | Needs the rendered template |
| Best for | Most real forms, complex validation | Small, simple forms |
If you are not sure, pick reactive forms. They scale better and are easier to test.
Template-driven version
Template-driven forms use ngModel to bind inputs to properties on the component. Angular creates the FormControls for you.
import { Component } from '@angular/core';
import { FormsModule, NgForm } from '@angular/forms';
@Component({
selector: 'app-signup-td',
standalone: true,
imports: [FormsModule],
templateUrl: './signup-td.component.html',
})
export class SignupTdComponent {
model = { email: '', password: '' };
submit(form: NgForm) {
if (form.invalid) return;
console.log('Sign up', this.model);
}
}
<form #f="ngForm" (ngSubmit)="submit(f)">
<input name="email" type="email" [(ngModel)]="model.email" required email #email="ngModel" />
@if (email.invalid && email.touched) {
<small>Please enter a valid email.</small>
}
<input name="password" type="password" [(ngModel)]="model.password" required minlength="8" />
<button type="submit" [disabled]="f.invalid">Create account</button>
</form>
Every input needs a name attribute, otherwise ngModel cannot register it with the parent form. Validation is declared with HTML attributes (required, minlength, email) that Angular turns into validator directives.
Reactive version
With reactive forms you build the model in TypeScript and bind the template to it.
import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
@Component({
selector: 'app-signup',
standalone: true,
imports: [ReactiveFormsModule],
templateUrl: './signup.component.html',
})
export class SignupComponent {
private fb = inject(FormBuilder).nonNullable;
form = this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
});
submit() {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
const { email, password } = this.form.getRawValue(); // both typed as string
console.log('Sign up', email, password);
}
}
<form [formGroup]="form" (ngSubmit)="submit()">
<input formControlName="email" type="email" />
@if (form.controls.email.hasError('email') && form.controls.email.touched) {
<small>Please enter a valid email.</small>
}
<input formControlName="password" type="password" />
@if (form.controls.password.hasError('minlength')) {
<small>At least 8 characters.</small>
}
<button type="submit">Create account</button>
</form>
Using inject(FormBuilder).nonNullable makes every control non-nullable, so getRawValue() returns { email: string; password: string } instead of string | null. Calling form.reset() then resets to the initial values rather than null.
Typed forms
Since Angular 14, reactive forms are strictly typed. The compiler catches mistakes like reading a control that does not exist or assigning the wrong type:
this.form.controls.email.setValue(42); // error: number is not assignable to string
this.form.controls.username; // error: property 'username' does not exist
If you need a control whose type is not inferable from the initial value, declare it explicitly:
birthDate = new FormControl<Date | null>(null);
Custom validators
A validator is just a function that receives a control and returns null (valid) or an error object. Here is a cross-field validator that checks two passwords match:
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
export const passwordsMatch: ValidatorFn = (group: AbstractControl): ValidationErrors | null => {
const password = group.get('password')?.value;
const confirm = group.get('confirm')?.value;
return password && confirm && password !== confirm ? { passwordsMismatch: true } : null;
};
form = this.fb.group(
{
password: ['', [Validators.required, Validators.minLength(8)]],
confirm: ['', Validators.required],
},
{ validators: passwordsMatch },
);
Because the validator is attached to the group, the error appears on form.errors, not on an individual control.
Async validators
Async validators return an Observable or Promise. A common case is checking whether an email is already registered:
import { AsyncValidatorFn } from '@angular/forms';
import { map, of, switchMap, timer } from 'rxjs';
export function emailAvailable(api: UserApi): AsyncValidatorFn {
return (control) =>
timer(400).pipe( // debounce while the user types
switchMap(() => (control.value ? api.isEmailTaken(control.value) : of(false))),
map((taken) => (taken ? { emailTaken: true } : null)),
);
}
email = this.fb.control('', {
validators: [Validators.required, Validators.email],
asyncValidators: [emailAvailable(inject(UserApi))],
updateOn: 'blur',
});
updateOn: 'blur' avoids calling the API on every keystroke. While the check runs, control.status is 'PENDING', which you can use to show a spinner.
Dynamic fields with FormArray
Reactive forms make variable-length lists simple. Here is a form where the user can add any number of phone numbers:
form = this.fb.group({
name: ['', Validators.required],
phones: this.fb.array([this.fb.control('')]),
});
get phones() {
return this.form.controls.phones;
}
addPhone() {
this.phones.push(this.fb.control(''));
}
removePhone(index: number) {
this.phones.removeAt(index);
}
<div formArrayName="phones">
@for (phone of phones.controls; track $index) {
<input [formControlName]="$index" />
<button type="button" (click)="removePhone($index)">Remove</button>
}
</div>
<button type="button" (click)="addPhone()">Add phone</button>
Doing the same with template-driven forms means managing an array of models and unique name attributes by hand.
Reacting to changes
Every control exposes valueChanges and statusChanges observables. To turn them into signals, use toSignal:
import { toSignal } from '@angular/core/rxjs-interop';
emailValue = toSignal(this.form.controls.email.valueChanges, { initialValue: '' });
A typical use is enabling or disabling another field:
this.form.controls.hasCompany.valueChanges.subscribe((has) => {
has ? this.form.controls.companyName.enable() : this.form.controls.companyName.disable();
});
Remember that getRawValue() includes disabled controls, while value skips them.
Testing
Reactive forms can be tested without rendering anything:
it('rejects short passwords', () => {
const component = TestBed.runInInjectionContext(() => new SignupComponent());
component.form.setValue({ email: '[email protected]', password: '123' });
expect(component.form.controls.password.hasError('minlength')).toBeTrue();
});
With template-driven forms, the controls only exist after the template renders, so tests need a fixture, detectChanges() and often whenStable().
Which one should you use?
- Use reactive forms for anything with cross-field rules, async validation, dynamic fields, or forms you want to unit test. This is most production forms.
- Use template-driven forms for small, static forms such as a newsletter box or a search field, where the extra setup does not pay off.
- Do not mix both approaches in the same form. Mixing
ngModelwithformControlNameon the same control is deprecated and leads to confusing behaviour.
Common mistakes
- Forgetting to import
ReactiveFormsModuleorFormsModulein a standalone component. The template then fails with “Can’t bind to ‘formGroup'”. - Missing
nameon inputs in template-driven forms. - Reading
form.valueand wondering why disabled fields are missing. UsegetRawValue(). - Subscribing to
valueChangeswithout unsubscribing in long-lived components. UsetakeUntilDestroyed()ortoSignal. - Showing validation errors before the user touched the field. Check
touchedordirtybefore displaying messages.



