Angular Intermediate #1: Reactive Forms and Form Validation
After Basics #7 covered HttpClient, we’ve made one full pass through the core tools of a small Angular app. Form input has been handled with a quick [(ngModel)] along the way. But once you reach production-grade forms, you hit limits fast: validation rules pile up on a single field, error messages need to differ per field, and forms with dynamically growing items like a tag input start showing up. That’s when it’s time to bring out the real tools.
In the first post of the Angular Intermediate series, we dig into that tool — Reactive Forms. You define the model directly in code and drive the form from there, which handles large forms, complex validation, and dynamic forms equally well.
Two form approaches — a one-line comparison #
Angular ships two official ways to handle forms.
- Template-driven Forms — you attach
[(ngModel)]to a template and Angular builds the model behind the scenes. Lightweight for small forms and simple validation. - Reactive Forms — you build the model yourself in TypeScript with
FormGroupandFormControl, and the template binds to that model. The model is explicit, so it’s strong for large forms, dynamic forms, and tests.
This post focuses on Reactive Forms; Template-driven gets its own post in #2. You don’t have to pick just one, but for a real project, defaulting to Reactive is the safer bet.
Setting up ReactiveFormsModule #
In a standalone component, add ReactiveFormsModule directly to the component’s imports array. No separate module is required.
import { Component } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
@Component({
selector: 'app-signup-form',
standalone: true,
imports: [ReactiveFormsModule],
templateUrl: './signup-form.component.html',
})
export class SignupFormComponent {}ReactiveFormsModule in the imports of AppModule once. In the standalone era, each component that uses it imports the modules it needs directly. It looks like more work at first, but the big win is that each component carries its own dependencies.FormControl and FormGroup — the primitives #
Reactive Forms start with two classes.
FormControl— holds the state of a single input field (value, validation, errors, dirty/touched, etc.)FormGroup— bundles multipleFormControls by name into a single form
At the simplest, a single FormControl is enough.
import { FormControl } from '@angular/forms';
name = new FormControl('');In real forms you almost always have multiple fields, so FormGroup is the everyday tool. The standard pattern uses the helper FormBuilder for readability.
import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
@Component({
selector: 'app-signup-form',
standalone: true,
imports: [ReactiveFormsModule],
templateUrl: './signup-form.component.html',
})
export class SignupFormComponent {
private fb = inject(FormBuilder);
form = this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
});
}Each item in fb.group is an array of [initial value, validators]. Just looking at this single block, you see the form’s structure and rules at a glance. The core appeal of Reactive Forms is that the model is explicit in code.
Connecting to the template #
The template just connects to that model. Wrap the group with [formGroup] and match each input by formControlName.
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<label>
Email
<input type="email" formControlName="email" />
</label>
<label>
Password
<input type="password" formControlName="password" />
</label>
<button type="submit" [disabled]="form.invalid">Sign up</button>
</form>onSubmit() {
if (this.form.invalid) return;
console.log('signup:', this.form.value);
}form.value pulls the entire form value at once, and form.invalid blocks submission. Component code and template share the same model, so you can work with form state freely from either side.
Validation (Validators) #
Validators from @angular/forms ships the validators you’ll reach for most often.
Validators.required— must not be emptyValidators.minLength(n)/Validators.maxLength(n)— length limitsValidators.email— email formatValidators.pattern(regex)— regex matchValidators.min(n)/Validators.max(n)— numeric range
To stack multiple validators on a single field, just pass them as an array. (Validators.compose([...]), which you’ll see in older material, does the same thing — you don’t actually need it.)
form = this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: [
'',
[
Validators.required,
Validators.minLength(8),
Validators.pattern(/(?=.*[A-Z])(?=.*\d)/), // 1+ uppercase, 1+ digit
],
],
});Reading form state — touched / dirty / valid / errors #
FormControl and FormGroup don’t just hold values. They also track interaction state.
touched— whether the user has focused and then blurred the field at least once (true after blur)dirty— whether the user has changed the value at least oncevalid/invalid— whether validation passeserrors— which validators failed, as an object ({ required: true },{ minlength: { ... } }, etc.)
Combine these to achieve the natural UX of “only show the error message after the user has touched the field”.
<label>
Email
<input type="email" formControlName="email" />
@if (form.controls.email.touched && form.controls.email.invalid) {
@if (form.controls.email.errors?.['required']) {
<small class="error">Please enter your email.</small>
} @else if (form.controls.email.errors?.['email']) {
<small class="error">That doesn't look like a valid email.</small>
}
}
</label>If spelling out form.controls.email every time gets long, the common pattern is to add a getter on the component.
get email() { return this.form.controls.email; }@if (email.touched && email.errors?.['required']) {
<small class="error">Please enter your email.</small>
}touched) feels much more natural. If red messages appear the moment the page loads, it feels like the app is scolding the user before they’ve done anything. On submit, though, the common pattern is markAllAsTouched() to mark every field touched at once and surface all missing fields together.Custom Validator #
If the built-in validators don’t cover your rule, you write your own. A Validator is, in the end, just a function — it takes an AbstractControl, returns null on pass, and a ValidationErrors object on fail.
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
export function forbiddenName(forbidden: string): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const value = (control.value ?? '').toString().toLowerCase();
return value.includes(forbidden.toLowerCase())
? { forbiddenName: { forbidden } }
: null;
};
}A function returning another function may look odd, but adding this wrapper lets you build a validator that accepts an outer argument (forbidden). Usage is identical to any other validator.
nickname: ['', [Validators.required, forbiddenName('admin')]],For a more realistic example, here’s a group-level validator that checks whether two fields match. It’s the classic password-confirmation pattern.
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
export function matchValidator(a: string, b: string): ValidatorFn {
return (group: AbstractControl): ValidationErrors | null => {
const valueA = group.get(a)?.value;
const valueB = group.get(b)?.value;
return valueA === valueB ? null : { mismatch: true };
};
}form = this.fb.group(
{
password: ['', [Validators.required, Validators.minLength(8)]],
passwordConfirm: ['', Validators.required],
},
{ validators: matchValidator('password', 'passwordConfirm') },
);A validator attached to the group like this is read off the group itself: form.errors?.['mismatch'].
FormArray — dynamic items #
When the number of form items isn’t known up front — say, an input where the user freely adds and removes tags — use FormArray. It’s a collection of controls accessed by index, as the name suggests.
import { Component, inject } from '@angular/core';
import {
FormArray,
FormBuilder,
FormControl,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
@Component({
selector: 'app-tag-form',
standalone: true,
imports: [ReactiveFormsModule],
templateUrl: './tag-form.component.html',
})
export class TagFormComponent {
private fb = inject(FormBuilder);
form = this.fb.group({
title: ['', Validators.required],
tags: this.fb.array<FormControl<string>>([]),
});
get tags() {
return this.form.controls.tags;
}
addTag() {
this.tags.push(this.fb.control('', { nonNullable: true }));
}
removeTag(i: number) {
this.tags.removeAt(i);
}
}In the template, use formArrayName and bind each control by index.
<form [formGroup]="form">
<input formControlName="title" placeholder="Title" />
<div formArrayName="tags">
@for (tag of tags.controls; track $index; let i = $index) {
<div>
<input [formControlName]="i" placeholder="Tag" />
<button type="button" (click)="removeTag(i)">Remove</button>
</div>
}
</div>
<button type="button" (click)="addTag()">+ Add tag</button>
</form>addTag pushes a new control and removeTag removes one by index. Validators can sit on each FormControl individually, or on the FormArray itself for rules like “at least one item required”.
Async validation (AsyncValidator) #
Validations like checking whether an email is already taken — where you need to ask the server — can’t be done synchronously. Use an async validator that returns an Observable (or Promise).
import { AsyncValidatorFn, AbstractControl } from '@angular/forms';
import { debounceTime, map, switchMap, of, take } from 'rxjs';
import { UserService } from '../user.service';
export function emailTakenValidator(users: UserService): AsyncValidatorFn {
return (control: AbstractControl) =>
of(control.value).pipe(
debounceTime(300),
switchMap(value =>
value ? users.checkEmailTaken(value) : of(false),
),
map(taken => (taken ? { emailTaken: true } : null)),
take(1),
);
}email: [
'',
{
validators: [Validators.required, Validators.email],
asyncValidators: [emailTakenValidator(inject(UserService))],
updateOn: 'blur',
},
],For async validation, you typically reduce the call frequency — either with debounceTime so the server isn’t hit on every keystroke, or with updateOn: 'blur' so validation runs only when the field loses focus. While validation is in flight, control.pending is true, so you can show a “Checking…” indicator.
Wrapping up #
In this post we made one pass through the core of Reactive Forms.
- Add
ReactiveFormsModuleto a standalone component’simports - Spell out the model in code with
FormBuilder.group({...}) - Connect the template via
[formGroup]+formControlName - Stack
Validatorsas an array per field - Build natural error UX with
touched+errors - Custom Validators are functions of shape
(control) => ValidationErrors | null FormArrayfor dynamic items,AsyncValidatorfor server-dependent checks
In the next post, #2 Template-driven Forms, we cover the Template-driven approach — a good fit for short forms and simple screens. Comparing them side-by-side should give you a feel for which to reach for in a given situation. This is the real start of the intermediate series — take it one step at a time.