Forms intermediate

Signal-Based Form Validation

Build reactive forms with real-time validation using signals—no Angular Forms needed. Perfect for simple forms with complex validation logic.

Alex Muturi
June 5, 2025
20 min read
#signals #forms #validation #reactivity #patterns

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:

  • FormArray for dynamic form fields
  • Deep integration with Angular Material
  • Complex nested form groups
  • Template-driven forms with directives
  • Built-in ng-dirty, ng-touched CSS 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

Additional Resources


Difficulty: Intermediate
Category: Forms
Tags: signals, forms, validation, reactivity, patterns

Share this article