Signal-Based Form Validation
Angular Forms are powerful but can be heavy for simple use cases. This recipe shows you how to build a lightweight, reactive form validation system using signals that's perfect for forms with complex validation logic.
When to Use This Pattern
Use signal-based forms when:
- ✅ You need simple forms with custom validation
- ✅ You want full type safety
- ✅ You prefer explicit control over form state
- ✅ You want to avoid Angular Forms boilerplate
- ✅ You need real-time validation feedback
Use Angular Forms when:
- You need complex form arrays
- You need deep integration with Angular Material
- You're building a form builder
- You need FormGroup/FormArray abstractions
The Pattern
We'll create a reusable createFormField() function that returns a reactive field with built-in validation:
interface FormField<T> {
value: WritableSignal<T>;
touched: WritableSignal<boolean>;
dirty: WritableSignal<boolean>;
errors: Signal<string[]>;
valid: Signal<boolean>;
}
function createBaseFormField<T>(
initialValue: T,
validators: Array<(value: T) => string | null> = []
): FormField<T> {
const value = signal(initialValue);
const touched = signal(false);
const dirty = signal(false);
const errors = computed(() => {
const currentValue = value();
return validators
.map(validator => validator(currentValue))
.filter((error): error is string => error !== null);
});
const valid = computed(() => errors().length === 0);
return { value, touched, dirty, errors, valid };
}
Complete Working Example
Here's a full registration form with signal-based validation:
import { Component, signal, computed, ChangeDetectionStrategy } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { TitleCasePipe, JsonPipe } from '@angular/common';
// ============================================================================
// VALIDATORS
// ============================================================================
function requiredValidator(value: string): string | null {
return value.trim().length === 0 ? 'This field is required' : null;
}
function minLengthValidator(min: number) {
return (value: string): string | null => {
return value.length < min
? `Must be at least ${min} characters`
: null;
};
}
function emailValidator(value: string): string | null {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return !emailRegex.test(value) ? 'Invalid email address' : null;
}
function matchValidator(otherValue: () => string, fieldName: string) {
return (value: string): string | null => {
return value !== otherValue()
? `Must match ${fieldName}`
: null;
};
}
// ============================================================================
// FORM FIELD TYPE
// ============================================================================
interface FormField<T> {
value: WritableSignal<T>;
touched: WritableSignal<boolean>;
dirty: WritableSignal<boolean>;
errors: Signal<string[]>;
valid: Signal<boolean>;
}
function createFormField<T>(
initialValue: T,
validators: Array<(value: T) => string | null> = []
): FormField<T> {
const value = signal<T>(initialValue);
const touched = signal(false);
const dirty = signal(false);
const errors = computed(() => {
const currentValue = value();
return validators
.map(validator => validator(currentValue))
.filter((error): error is string => error !== null);
});
const valid = computed(() => errors().length === 0);
return { value, touched, dirty, errors, valid };
}
// ============================================================================
// COMPONENT
// ============================================================================
@Component({
selector: 'app-registration-form',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [FormsModule, TitleCasePipe, JsonPipe],
template: `
<div class="form-container">
<h2>Create Account</h2>
<form (ngSubmit)="onSubmit()" #form="ngForm">
<!-- Username Field -->
<div class="form-group">
<label for="username">
Username *
@if (usernameField.dirty() && usernameField.errors().length > 0) {
<span class="field-status error">
{{ usernameField.errors()[0] }}
</span>
}
</label>
<input
id="username"
type="text"
[(ngModel)]="username"
(ngModelChange)="handleUsernameChange($event)"
(blur)="usernameField.touched.set(true)"
[class.invalid]="usernameField.touched() && !usernameField.valid()"
name="username"
placeholder="Choose a username"
/>
</div>
<!-- Email Field -->
<div class="form-group">
<label for="email">
Email *
@if (emailField.dirty() && emailField.errors().length > 0) {
<span class="field-status error">
{{ emailField.errors()[0] }}
</span>
}
</label>
<input
id="email"
type="email"
[(ngModel)]="email"
(ngModelChange)="handleEmailChange($event)"
(blur)="emailField.touched.set(true)"
[class.invalid]="emailField.touched() && !emailField.valid()"
name="email"
placeholder="your@email.com"
/>
</div>
<!-- Password Field -->
<div class="form-group">
<label for="password">
Password *
@if (passwordField.dirty() && passwordField.errors().length > 0) {
<span class="field-status error">
{{ passwordField.errors()[0] }}
</span>
}
</label>
<input
id="password"
type="password"
[(ngModel)]="password"
(ngModelChange)="handlePasswordChange($event)"
(blur)="passwordField.touched.set(true)"
[class.invalid]="passwordField.touched() && !passwordField.valid()"
name="password"
placeholder="Enter password"
/>
<!-- Password strength indicator -->
@if (passwordField.value()) {
<div class="password-strength">
<div
class="strength-bar"
[class.weak]="passwordStrength() === 'weak'"
[class.medium]="passwordStrength() === 'medium'"
[class.strong]="passwordStrength() === 'strong'"
>
</div>
<span class="strength-label">
{{ passwordStrength() | titlecase }}
</span>
</div>
}
</div>
<!-- Confirm Password Field -->
<div class="form-group">
<label for="confirmPassword">
Confirm Password *
@if (confirmPasswordField.dirty() && confirmPasswordField.errors().length > 0) {
<span class="field-status error">
{{ confirmPasswordField.errors()[0] }}
</span>
}
</label>
<input
id="confirmPassword"
type="password"
[(ngModel)]="confirmPassword"
(ngModelChange)="handleConfirmPasswordChange($event)"
(blur)="confirmPasswordField.touched.set(true)"
[class.invalid]="confirmPasswordField.touched() && !confirmPasswordField.valid()"
name="confirmPassword"
placeholder="Confirm password"
/>
</div>
<!-- Terms checkbox -->
<div class="form-group checkbox-group">
<label>
<input
type="checkbox"
[(ngModel)]="acceptTerms"
(ngModelChange)="termsField.value.set($event); termsField.dirty.set(true)"
name="acceptTerms"
/>
<span>
I accept the terms and conditions *
@if (termsField.touched() && !termsField.valid()) {
<span class="field-status error">Required</span>
}
</span>
</label>
</div>
<!-- Form-level errors -->
@if (formTouched() && !formValid()) {
<div class="form-errors">
<p>Please fix the following errors:</p>
<ul>
@for (error of formErrors(); track error) {
<li>{{ error }}</li>
}
</ul>
</div>
}
<!-- Submit button -->
<button
type="submit"
class="submit-btn"
[disabled]="!formValid() || isSubmitting()"
(click)="formTouched.set(true)"
>
@if (isSubmitting()) {
<span>Creating account...</span>
} @else {
<span>Create Account</span>
}
</button>
<!-- Success message -->
@if (submitSuccess()) {
<div class="success-message">
✓ Account created successfully!
</div>
}
</form>
<!-- Debug panel (remove in production) -->
<div class="debug-panel">
<h4>Form State (Debug)</h4>
<pre>{{ debugInfo() | json }}</pre>
</div>
</div>
`,
styles: [`
.form-container {
max-width: 500px;
margin: 2rem auto;
padding: 2rem;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
h2 {
margin-top: 0;
color: #333;
}
.form-group {
margin-bottom: 1.5rem;
}
label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #555;
}
input[type="text"],
input[type="email"],
input[type="password"] {
width: 100%;
padding: 0.75rem;
border: 2px solid #ddd;
border-radius: 4px;
font-size: 1rem;
transition: border-color 0.3s;
}
input:focus {
outline: none;
border-color: #007bff;
}
input.invalid {
border-color: #dc3545;
}
.field-status {
margin-left: 0.5rem;
font-size: 0.85rem;
}
.field-status.error {
color: #dc3545;
}
.password-strength {
margin-top: 0.5rem;
}
.strength-bar {
height: 4px;
border-radius: 2px;
transition: all 0.3s;
margin-bottom: 0.25rem;
}
.strength-bar.weak {
width: 33%;
background: #dc3545;
}
.strength-bar.medium {
width: 66%;
background: #ffc107;
}
.strength-bar.strong {
width: 100%;
background: #28a745;
}
.strength-label {
font-size: 0.85rem;
color: #666;
}
.checkbox-group label {
display: flex;
align-items: center;
gap: 0.5rem;
}
.checkbox-group input[type="checkbox"] {
width: auto;
}
.form-errors {
background: #f8d7da;
border: 1px solid #f5c6cb;
color: #721c24;
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.form-errors p {
margin: 0 0 0.5rem 0;
font-weight: bold;
}
.form-errors ul {
margin: 0;
padding-left: 1.5rem;
}
.submit-btn {
width: 100%;
padding: 0.75rem;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: background 0.3s;
}
.submit-btn:hover:not(:disabled) {
background: #0056b3;
}
.submit-btn:disabled {
background: #ccc;
cursor: not-allowed;
}
.success-message {
margin-top: 1rem;
padding: 1rem;
background: #d4edda;
border: 1px solid #c3e6cb;
color: #155724;
border-radius: 4px;
text-align: center;
font-weight: bold;
}
.debug-panel {
margin-top: 2rem;
padding: 1rem;
background: #f8f9fa;
border-radius: 4px;
border: 1px solid #dee2e6;
}
.debug-panel h4 {
margin-top: 0;
color: #666;
}
.debug-panel pre {
background: white;
padding: 1rem;
border-radius: 4px;
overflow-x: auto;
font-size: 0.85rem;
}
`]
})
export class RegistrationFormComponent {
// Form field values (for ngModel)
username = '';
email = '';
password = '';
confirmPassword = '';
acceptTerms = false;
// Form fields with validation
usernameField = createFormField<string>('', [
requiredValidator,
minLengthValidator(3)
]);
emailField = createFormField<string>('', [
requiredValidator,
emailValidator
]);
passwordField = createFormField<string>('', [
requiredValidator,
minLengthValidator(8)
]);
confirmPasswordField = createFormField<string>('', [
requiredValidator,
matchValidator(() => this.passwordField.value(), 'password')
]);
termsField = createFormField<boolean>(false, [
(value: boolean) => value ? null : 'Required'
]);
// Form-level state
formTouched = signal(false);
isSubmitting = signal(false);
submitSuccess = signal(false);
// Computed: Password strength
passwordStrength = computed(() => {
const pwd = this.passwordField.value();
if (pwd.length < 8) return 'weak';
const hasUpper = /[A-Z]/.test(pwd);
const hasLower = /[a-z]/.test(pwd);
const hasNumber = /[0-9]/.test(pwd);
const hasSpecial = /[!@#$%^&*]/.test(pwd);
const score = [hasUpper, hasLower, hasNumber, hasSpecial]
.filter(Boolean).length;
if (score >= 3) return 'strong';
if (score >= 2) return 'medium';
return 'weak';
});
// Computed: Form validity
formValid = computed(() =>
this.usernameField.valid() &&
this.emailField.valid() &&
this.passwordField.valid() &&
this.confirmPasswordField.valid() &&
this.termsField.valid()
);
// Computed: Collect all errors
formErrors = computed(() => {
const errors: string[] = [];
if (!this.usernameField.valid()) {
errors.push(...this.usernameField.errors());
}
if (!this.emailField.valid()) {
errors.push(...this.emailField.errors());
}
if (!this.passwordField.valid()) {
errors.push(...this.passwordField.errors());
}
if (!this.confirmPasswordField.valid()) {
errors.push(...this.confirmPasswordField.errors());
}
if (!this.termsField.valid()) {
errors.push('You must accept the terms');
}
return errors;
});
// Debug info
debugInfo = computed(() => ({
formValid: this.formValid(),
formTouched: this.formTouched(),
fields: {
username: {
value: this.usernameField.value(),
valid: this.usernameField.valid(),
touched: this.usernameField.touched(),
errors: this.usernameField.errors()
},
email: {
value: this.emailField.value(),
valid: this.emailField.valid(),
errors: this.emailField.errors()
},
password: {
valid: this.passwordField.valid(),
strength: this.passwordStrength(),
errors: this.passwordField.errors()
}
}
}));
// Change handlers
handleUsernameChange(value: string) {
this.usernameField.value.set(value);
this.usernameField.dirty.set(true);
}
handleEmailChange(value: string) {
this.emailField.value.set(value);
this.emailField.dirty.set(true);
}
handlePasswordChange(value: string) {
this.passwordField.value.set(value);
this.passwordField.dirty.set(true);
// Revalidate confirm password when password changes
if (this.confirmPasswordField.dirty()) {
this.confirmPasswordField.value.set(this.confirmPassword);
}
}
handleConfirmPasswordChange(value: string) {
this.confirmPasswordField.value.set(value);
this.confirmPasswordField.dirty.set(true);
}
onSubmit() {
// Mark all fields as touched
this.usernameField.touched.set(true);
this.emailField.touched.set(true);
this.passwordField.touched.set(true);
this.confirmPasswordField.touched.set(true);
this.termsField.touched.set(true);
this.formTouched.set(true);
if (!this.formValid()) {
console.log('Form is invalid:', this.formErrors());
return;
}
// Simulate API call
this.isSubmitting.set(true);
setTimeout(() => {
console.log('Form submitted:', {
username: this.usernameField.value(),
email: this.emailField.value(),
password: this.passwordField.value()
});
this.isSubmitting.set(false);
this.submitSuccess.set(true);
// Reset form after 3 seconds
setTimeout(() => {
this.resetForm();
}, 3000);
}, 1500);
}
resetForm() {
this.username = '';
this.email = '';
this.password = '';
this.confirmPassword = '';
this.acceptTerms = false;
this.usernameField.value.set('');
this.usernameField.touched.set(false);
this.usernameField.dirty.set(false);
this.emailField.value.set('');
this.emailField.touched.set(false);
this.emailField.dirty.set(false);
this.passwordField.value.set('');
this.passwordField.touched.set(false);
this.passwordField.dirty.set(false);
this.confirmPasswordField.value.set('');
this.confirmPasswordField.touched.set(false);
this.confirmPasswordField.dirty.set(false);
this.termsField.value.set(false);
this.termsField.touched.set(false);
this.formTouched.set(false);
this.submitSuccess.set(false);
}
}
Adding Async Validation
For server-side validation (like checking if username is available):
import { signal, computed, effect } from '@angular/core';
import { inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
interface AsyncFormField<T> extends FormField<T> {
validating: Signal<boolean>;
asyncErrors: Signal<string[]>;
}
function createAsyncFormField<T>(
initialValue: T,
syncValidators: Array<(value: T) => string | null> = [],
asyncValidator?: (value: T) => Promise<string | null>
): AsyncFormField<T> {
const baseField = createFormField(initialValue, syncValidators);
const validating = signal(false);
const asyncErrors = signal<string[]>([]);
if (asyncValidator) {
effect((onCleanup) => {
const value = baseField.value();
// Skip if sync validation fails
if (!baseField.valid()) {
asyncErrors.set([]);
return;
}
validating.set(true);
let cancelled = false;
asyncValidator(value).then(error => {
if (cancelled) return;
validating.set(false);
asyncErrors.set(error ? [error] : []);
});
onCleanup(() => {
cancelled = true;
validating.set(false);
});
});
}
return { ...baseField, validating, asyncErrors };
}
// Usage
@Component({/*...*/})
export class AsyncFormComponent {
private http = inject(HttpClient);
usernameField = createAsyncFormField(
'',
[requiredValidator, minLengthValidator(3)],
async (username: string) => {
const response = await this.http
.get<{ available: boolean }>(`/api/check-username/${username}`)
.toPromise();
return response?.available
? null
: 'Username is already taken';
}
);
}
Reusable Validators Library
Create a validators module for reuse across your app:
// validators.ts
const Validators = {
required: (value: string) =>
value.trim().length === 0 ? 'This field is required' : null,
minLength: (min: number) => (value: string) =>
value.length < min ? `Must be at least ${min} characters` : null,
maxLength: (max: number) => (value: string) =>
value.length > max ? `Must be at most ${max} characters` : null,
email: (value: string) => {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return !regex.test(value) ? 'Invalid email address' : null;
},
pattern: (regex: RegExp, message: string) => (value: string) =>
!regex.test(value) ? message : null,
match: (otherValue: () => string, fieldName: string) => (value: string) =>
value !== otherValue() ? `Must match ${fieldName}` : null,
url: (value: string) => {
try {
new URL(value);
return null;
} catch {
return 'Invalid URL';
}
},
phoneNumber: (value: string) => {
const regex = /^\+?[\d\s-()]+$/;
return !regex.test(value) ? 'Invalid phone number' : null;
}
};
Key Benefits
✅ Type-safe - Full TypeScript support
✅ Lightweight - No Angular Forms overhead
✅ Reactive - Real-time validation
✅ Composable - Build custom validators easily
✅ Testable - Pure functions are easy to test
✅ Explicit - Clear control flow
When to Use Angular Forms Instead
Consider Angular Forms if you need:
FormArrayfor dynamic form fields- Deep integration with Angular Material
- Complex nested form groups
- Template-driven forms with directives
- Built-in
ng-dirty,ng-touchedCSS classes
Testing
Testing signal-based forms is straightforward:
describe('createFormField', () => {
it('should validate required field', () => {
const field = createFormField<string>('', [requiredValidator]);
expect(field.valid()).toBe(false);
expect(field.errors()).toContain('This field is required');
field.value.set('test');
expect(field.valid()).toBe(true);
expect(field.errors()).toEqual([]);
});
it('should track touched state', () => {
const field = createFormField<string>('');
expect(field.touched()).toBe(false);
field.touched.set(true);
expect(field.touched()).toBe(true);
});
});
Related Content
- Angular Signals: The Basics - Understand signal fundamentals
- Computed Signals Explained - Learn about derived state
- Debounced Search Input - Optimize input performance
Additional Resources
Difficulty: Intermediate
Category: Forms
Tags: signals, forms, validation, reactivity, patterns