Angular Forms โ Validation and Form State (Part 3) ๐ฅ๐
In Part 1 and Part 2, we covered model setup, UI binding, and data flow across Template-driven, Reactive, and Signal Forms.
Photo by Franck on Unsplash
Angular Forms โ Validation and Form State (Part 3) ๐ฅ๐
In Part 1 and Part 2, we covered model setup, UI binding, and data flow across Template-driven, Reactive, and Signal Forms.
In this part, we focus on what makes forms production-ready: validation and field state management.
To keep examples comparable, we use the same small domain throughout: a login form with email and password.
โ ๏ธ Signal Forms are experimental The API may evolve in future Angular releases. For long-term stability and mature tooling, Reactive Forms remain the most robust and battle-tested choice today.
๐งฑ Baseline: the same form in 3 approaches
The goal is not to crown a winner, but to make the differences explicit so you can choose or migrate with confidence.
Template-driven
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-login-template',
imports: [FormsModule],
template: `
<form #f="ngForm" (ngSubmit)="submit()">
<label>
Email
<input name="email" [(ngModel)]="model.email" />
</label>
<label>
Password
<input
name="password"
type="password"
[(ngModel)]="model.password"
/>
</label>
<button type="submit">Login</button>
</form>
`
})
export class LoginTemplateComponent {
model = { email: '', password: '' };
submit(): void {
console.log('submit', this.model);
}
}
Reactive
import { Component } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
@Component({
selector: 'app-login-reactive',
imports: [ReactiveFormsModule],
template: `
<form (ngSubmit)="submit()">
<label>
Email
<input [formControl]="email" />
</label>
<label>
Password
<input type="password" [formControl]="password" />
</label>
<button type="submit">Login</button>
</form>
`
})
export class LoginReactiveComponent {
email = new FormControl('', { nonNullable: true });
password = new FormControl('', { nonNullable: true });
submit(): void {
console.log('submit',
{
email: this.email.value,
password: this.password.value
}
);
}
}
Signal Forms
import { Component, signal } from '@angular/core';
import { form, FormField } from '@angular/forms/signals';
@Component({
selector: 'app-login-signal',
imports: [FormField],
template: `
<form (ngSubmit)="submit()">
<label>
Email
<input [formField]="loginForm.email" />
</label>
<label>
Password
<input type="password" [formField]="loginForm.password" />
</label>
<button type="submit">Login</button>
</form>
`
})
export class LoginSignalComponent {
model = signal({ email: '', password: '' });
loginForm = form(this.model);
submit(): void {
console.log('submit', this.model());
}
}
๐ก๏ธ Built-in validators
Angular ships with familiar validators such as required, email, minLength, and pattern. What changes across approaches is where you declare them and how you read errors.
Template-driven: validators in the template
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-email-template',
imports: [FormsModule],
template: `
<form #f="ngForm">
<input
name="email"
[(ngModel)]="email"
required
email
#emailModel="ngModel"
/>
@if (emailModel.invalid && emailModel.touched) {
<p>
@if (emailModel.errors?.['required']) { <span>Email is required.</span> }
@if (emailModel.errors?.['email']) { <span>Invalid email format.</span> }
</p>
}
<button type="button" [disabled]="f.invalid">Continue</button>
</form>
`
})
export class EmailTemplateComponent {
email = '';
}
State is exposed via template references like #emailModel="ngModel".
Reactive: validators in the model
import { Component } from '@angular/core';
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
@Component({
selector: 'app-email-reactive',
imports: [ReactiveFormsModule],
template: `
<input [formControl]="email" />
@if (email.invalid && email.touched) {
<p>
@if (email.hasError('required')) { <span>Email is required.</span> }
@if (email.hasError('email')) { <span>Invalid email format.</span> }
</p>
}
`
})
export class EmailReactiveComponent {
email = new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.email]
});
}
Here, validation logic and state live in the FormControl API.
Signal Forms: reactive validation + state
Signal Forms expose state via signals such as valid(), invalid(), touched(), dirty(), pending(), and errors().
import { Component, signal } from '@angular/core';
import { form, FormField } from '@angular/forms/signals';
@Component({
selector: 'app-email-signal',
imports: [FormField],
template: `
<input [formField]="f.email" />
@if (f.email().invalid() && f.email().touched()) {
<p>
@if (f.email().errors()?.['required']) { <span>Email is required.</span> }
@if (f.email().errors()?.['email']) { <span>Invalid email format.</span> }
</p>
}
`
})
export class EmailSignalComponent {
model = signal({ email: '' });
f = form(this.model, (path) => {
path.email.addValidators(['required', 'email']);
});
}
๐ก Schema-first validation with Zod If you prefer framework-agnostic validation, you can plug a Zod schema into Signal Forms and use it as your single source of truth. I covered this approach in detail in my article on Zod + Angular Signal Forms, where validation works consistently across forms, HTTP, and runtime boundaries.
Centralizing validation logic at the schema level makes migrations significantly easier.
๐งญ Form state patterns that scale
In real applications, showing errors at the right time is just as important as defining them.
A practical strategy:
- show field errors only after the user interacts (
touched) - show form-level errors after submit
- disable submit when the form is
invalid(andpending, when applicable)
Letโs see how this looks in practice.
Template-driven
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-state-template',
imports: [FormsModule],
template: `
<form #f="ngForm" (ngSubmit)="submitted = true">
<input
name="email"
[(ngModel)]="email"
required
email
#m="ngModel"
/>
@if ((m.touched || submitted) && m.invalid) {
<p>Invalid email.</p>
}
<button type="submit" [disabled]="f.invalid">Submit</button>
</form>
`
})
export class StateTemplateComponent {
email = '';
submitted = false;
}
Here, you often need extra flags like submitted to control UX timing.
Reactive
import { Component } from '@angular/core';
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
@Component({
selector: 'app-state-reactive',
imports: [ReactiveFormsModule],
template: `
<form (ngSubmit)="submitted = true">
<input [formControl]="email" />
@if ((email.touched || submitted) && email.invalid) {
<p>Invalid email.</p>
}
<button type="submit" [disabled]="email.invalid">Submit</button>
</form>
`
})
export class StateReactiveComponent {
email = new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.email]
});
submitted = false;
}
State is fully available through the control instance. No template references required.
Signal Forms
import { Component, signal } from '@angular/core';
import { form, FormField } from '@angular/forms/signals';
@Component({
selector: 'app-state-signal',
imports: [FormField],
template: `
<form (ngSubmit)="submitted.set(true)">
<input [formField]="f.email" />
@if ((f.email().touched() || submitted()) && f.email().invalid()) {
<p>Invalid email.</p>
}
<button type="submit" [disabled]="f.invalid()">Submit</button>
</form>
`
})
export class StateSignalComponent {
model = signal({ email: '' });
submitted = signal(false);
f = form(this.model, (path) => {
path.email.addValidators(['required', 'email']);
});
}
Because state itself is signal-based, UI conditions stay fully reactive without extra subscriptions.
The conceptual pattern is the same across all approaches. What changes is how explicit and centralized the state management feels.
๐ง Architectural Comparison (Quick Take)
At this stage, the differences become clearer.
Template-driven forms are concise and approachable, but state and validation logic tend to spread across the template. As complexity grows, this can reduce clarity.
Reactive forms centralize validation and state inside the component class. This makes behavior predictable, easier to test, and more scalable for large forms.
Signal Forms aim to reduce boilerplate while keeping reactivity explicit. State becomes first-class and declarative, but the API is still evolving.
If you are building simple forms, Template-driven works. If you are building complex flows with strong validation needs, Reactive remains the safest bet. If you want modern reactivity with less ceremony and are comfortable with experimental APIs, Signal Forms are promising.
You can explore all the examples shown in this article in the associated โกStackBlitz project. It includes the three implementations side by side so you can compare behavior and state handling in real time.
๐ Whatโs Next
In Part 4, we will dive into advanced validation patterns: custom validators, cross-field rules, and async validation strategies.
If you found this helpful, follow me here and on **LinkedIn** for more deep dives into Angular, web performance, and modern frontend development.
See you in the next one! ๐ค๐ป โ G.
๋ฉํ๋ฐ์ดํฐ
- post_id
- 0f2db5b4961e
- slug
- angular-forms-validation-and-form-state-part-3-0f2db5b4961e
- url
- https://medium.com/@giorgio.galassi/angular-forms-validation-and-form-state-part-3-0f2db5b4961e
- canonical_url
- https://medium.com/@giorgio.galassi/angular-forms-validation-and-form-state-part-3-0f2db5b4961e
- author_url
- https://medium.com/@giorgio.galassi
- status
- ok
- fetched_at
- 2026-07-12 00:07:18