DEV Community

Cover image for Angular Signal-Based Forms: Why They're About to Change Everything You Know About Form Handling
Rajat
Rajat

Posted on

Angular Signal-Based Forms: Why They're About to Change Everything You Know About Form Handling

The Future of Angular Forms is Here, and It's Powered by Signals

⚠️ Important Note: Signal-Based Forms are currently an experimental feature in Angular. The APIs shown in this article may change when the feature is officially released. I'll keep updating this article as the feature evolves, so bookmark it for the latest patterns!

Have you ever found yourself writing the same FormGroup and FormControl setup over and over again? Or struggled with complex validation logic that felt more like wrestling with the framework than building features?

What if I told you that Angular's experimental Signal-Based Forms could eliminate 70% of your form boilerplate while making your code more reactive and maintainable?

By the end of this article, you'll understand:

  • How Signal-Based Forms work under the hood
  • When to choose them over Reactive and Template-Driven forms
  • How to implement complex validation scenarios with ease
  • Best practices for testing signal-based forms
  • Real-world patterns that you can use immediately

Let's dive into what might be the biggest game-changer for Angular developers since standalone components.

Why Signal-Based Forms Matter (And Why Now?)

Think about the last form you built. How much time did you spend on setup versus actual business logic?

Traditional Reactive Forms require you to:

  1. Define a FormGroup structure
  2. Map it to your data model
  3. Handle validation manually
  4. Sync state between form and component
  5. Deal with subscription management

Signal-Based Forms flip this approach. Instead of building forms from scratch, you start with your data model and let Angular generate the form structure automatically.

Here's what this looks like in practice:

Old Way (Reactive Forms)

export class OldProfileComponent {
  profileForm = new FormGroup({
    firstName: new FormControl('', [Validators.required, Validators.minLength(2)]),
    lastName: new FormControl('', [Validators.required, Validators.minLength(2)]),
    email: new FormControl('', [Validators.required, Validators.email]),
    notifyByEmail: new FormControl(false)
  });

  onSubmit() {
    if (this.profileForm.valid) {
      this.userService.updateProfile(this.profileForm.value);
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

New Way (Signal-Based Forms)

import { Component, signal } from '@angular/core';
import { form, Control, required, minLength, email } from '@angular/forms/signals';

@Component({
  selector: 'app-profile',
  imports: [Control],
  template: `<!-- template here -->`
})
export class ProfileComponent {
  user = signal({
    firstName: '',
    lastName: '',
    email: '',
    notifyByEmail: false
  });

  profileForm = form(this.user, (path) => [
    required(path.firstName, { message: 'First name is required' }),
    minLength(path.firstName, 2, { message: 'Must be at least 2 characters' }),
    required(path.lastName, { message: 'Last name is required' }),
    minLength(path.lastName, 2, { message: 'Must be at least 2 characters' }),
    required(path.email, { message: 'Email is required' }),
    email(path.email, { message: 'Invalid email format' }),

    // Conditional validation with 'when'
    required(path.email, {
      message: 'Email required when notifications are enabled',
      when: ({ valueOf }) => valueOf(path.notifyByEmail) === true
    })
  ]);

  async onSubmit() {
    await submit(this.profileForm, async () => {
      try {
        const response = await fetch('/api/users', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json'
          },
          body: JSON.stringify(this.profileForm.value())
        });

        if (!response.ok) {
          return [
            {
              kind: 'server',
              message: 'Failed to save user',
              field: this.profileForm.email
            }
          ];
        }

        return undefined; // success
      } catch (err) {
        return [{ kind: 'server', message: 'Unexpected error' }];
      }
    });
  }
}

Enter fullscreen mode Exit fullscreen mode

Notice the difference? The signal-based approach eliminates the manual form structure definition and provides built-in submission handling with the submit() function.

Setting Up Your First Signal-Based Form

Let's build a real-world example step by step. We'll create a user registration form that showcases the key features.

Step 1: Define Your Data Model

interface UserRegistration {
  personalInfo: {
    firstName: string;
    lastName: string;
    email: string;
  };
  preferences: {
    newsletter: boolean;
    notifications: boolean;
  };
  accountType: 'personal' | 'business';
  password: string;
  confirmPassword: string;
}

Enter fullscreen mode Exit fullscreen mode

Step 2: Create the Component

import { Component, signal, computed } from '@angular/core';
import { form, Control, required, email, minLength, pattern, submit } from '@angular/forms/signals';
import { CommonModule } from '@angular/common';

@Component({
  selector: 'app-registration',
  imports: [Control, CommonModule],
  template: `
    <form (ngSubmit)="onSubmit()">
      <fieldset>
        <legend>Personal Information</legend>

        <div class="field">
          <label for="firstName">First Name</label>
          <input
            id="firstName"
            type="text"
            [control]="registrationForm.personalInfo.firstName"
            placeholder="Enter your first name"
          />
          @if (registrationForm.personalInfo.firstName.errors(); as errors) {
            @for (error of errors; track error.code) {
              <span class="error">{{ error.message }}</span>
            }
          }
        </div>

        <div class="field">
          <label for="lastName">Last Name</label>
          <input
            id="lastName"
            type="text"
            [control]="registrationForm.personalInfo.lastName"
            placeholder="Enter your last name"
          />
          @if (registrationForm.personalInfo.lastName.errors(); as errors) {
            @for (error of errors; track error.code) {
              <span class="error">{{ error.message }}</span>
            }
          }
        </div>

        <div class="field">
          <label for="email">Email</label>
          <input
            id="email"
            type="email"
            [control]="registrationForm.personalInfo.email"
            placeholder="Enter your email"
          />
          @if (registrationForm.personalInfo.email.errors(); as errors) {
            @for (error of errors; track error.code) {
              <span class="error">{{ error.message }}</span>
            }
          }
        </div>

        <div class="field">
          <label for="accountType">Account Type</label>
          <select
            id="accountType"
            [control]="registrationForm.accountType"
          >
            <option value="personal">Personal</option>
            <option value="business">Business</option>
          </select>
        </div>
      </fieldset>

      <fieldset>
        <legend>Preferences</legend>

        <label class="checkbox">
          <input
            type="checkbox"
            [control]="registrationForm.preferences.newsletter"
          />
          Subscribe to newsletter
        </label>

        <label class="checkbox">
          <input
            type="checkbox"
            [control]="registrationForm.preferences.notifications"
          />
          Enable notifications
        </label>
      </fieldset>

      <fieldset>
        <legend>Security</legend>

        <div class="field">
          <label for="password">Password</label>
          <input
            id="password"
            type="password"
            [control]="registrationForm.password"
            placeholder="Enter password"
          />
          @if (registrationForm.password.errors(); as errors) {
            @for (error of errors; track error.code) {
              <span class="error">{{ error.message }}</span>
            }
          }
        </div>

        <div class="field">
          <label for="confirmPassword">Confirm Password</label>
          <input
            id="confirmPassword"
            type="password"
            [control]="registrationForm.confirmPassword"
            placeholder="Confirm your password"
          />
          @if (registrationForm.confirmPassword.errors(); as errors) {
            @for (error of errors; track error.code) {
              <span class="error">{{ error.message }}</span>
            }
          }
        </div>
      </fieldset>

      <div class="form-actions">
        <button
          type="submit"
          [disabled]="registrationForm.submitting() || !registrationForm.valid()"
        >
          @if (registrationForm.submitting()) {
            <span>Creating Account...</span>
          } @else {
            <span>Create Account</span>
          }
        </button>
      </div>

      @if (registrationForm.submitError(); as error) {
        <div class="submit-error">
          {{ error.message }}
        </div>
      }
    </form>
  `,
  styles: [`
    form {
      max-width: 600px;
      margin: 0 auto;
      padding: 2rem;
    }

    fieldset {
      border: 1px solid #ddd;
      border-radius: 8px;
      padding: 1.5rem;
      margin-bottom: 2rem;
    }

    legend {
      font-weight: bold;
      padding: 0 0.5rem;
    }

    .field {
      margin-bottom: 1rem;
    }

    label {
      display: block;
      margin-bottom: 0.5rem;
      font-weight: 500;
    }

    input[type="text"], input[type="email"], input[type="password"], select {
      width: 100%;
      padding: 0.75rem;
      border: 1px solid #ccc;
      border-radius: 4px;
      font-size: 1rem;
    }

    .checkbox {
      display: flex;
      align-items: center;
      margin-bottom: 1rem;
    }

    .checkbox input {
      width: auto;
      margin-right: 0.5rem;
    }

    .error {
      color: #e53e3e;
      font-size: 0.875rem;
      display: block;
      margin-top: 0.25rem;
    }

    .form-actions {
      margin-top: 2rem;
    }

    button {
      background-color: #3182ce;
      color: white;
      padding: 0.75rem 2rem;
      border: none;
      border-radius: 4px;
      font-size: 1rem;
      cursor: pointer;
      transition: background-color 0.2s;
    }

    button:hover:not(:disabled) {
      background-color: #2c5aa0;
    }

    button:disabled {
      opacity: 0.6;
      cursor: not-allowed;
    }

    .submit-error {
      background-color: #fed7d7;
      color: #c53030;
      padding: 1rem;
      border-radius: 4px;
      margin-top: 1rem;
    }
  `]
})
export class RegistrationComponent {
  userRegistration = signal<UserRegistration>({
    personalInfo: {
      firstName: '',
      lastName: '',
      email: ''
    },
    preferences: {
      newsletter: false,
      notifications: true
    },
    accountType: 'personal',
    password: '',
    confirmPassword: ''
  });

  registrationForm = form(this.userRegistration, (path) => [
    // Basic field validation
    required(path.personalInfo.firstName, {
      message: 'First name is required'
    }),
    minLength(path.personalInfo.firstName, 2, {
      message: 'First name must be at least 2 characters'
    }),

    required(path.personalInfo.lastName, {
      message: 'Last name is required'
    }),
    minLength(path.personalInfo.lastName, 2, {
      message: 'Last name must be at least 2 characters'
    }),

    required(path.personalInfo.email, {
      message: 'Email is required'
    }),
    email(path.personalInfo.email, {
      message: 'Please enter a valid email address'
    }),

    // Conditional validation using 'when'
    required(path.personalInfo.email, {
      message: 'Email required when notifications are enabled',
      when: ({ valueOf }) => valueOf(path.preferences.notifications) === true
    }),

    // Business email pattern only when newsletter is enabled
    pattern(path.personalInfo.email, /^[a-zA-Z0-9._%+-]+@(?!gmail|yahoo|hotmail|outlook)[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/, {
      message: 'Business email required for newsletter subscriptions',
      when: ({ valueOf }) => valueOf(path.preferences.newsletter) === true
    }),

    // Different validation based on account type
    minLength(path.personalInfo.firstName, 5, {
      message: 'Business accounts require longer names',
      when: ({ valueOf }) => valueOf(path.accountType) === 'business'
    }),

    required(path.password, {
      message: 'Password is required'
    }),
    minLength(path.password, 8, {
      message: 'Password must be at least 8 characters'
    }),

    required(path.confirmPassword, {
      message: 'Please confirm your password',
      when: ({ valueOf }) => valueOf(path.password) !== ''
    })
  ]);

  async onSubmit() {
    await submit(this.registrationForm, async () => {
      try {
        const response = await fetch('/api/users', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json'
          },
          body: JSON.stringify(this.registrationForm.value())
        });

        if (!response.ok) {
          return [
            {
              kind: 'server',
              message: 'Email already exists',
              field: this.registrationForm.personalInfo.email
            }
          ];
        }

        const result = await response.json();
        console.log('Registration successful:', result);
        return undefined;

      } catch (error) {
        return [
          { kind: 'server', message: 'Network error. Please try again.' }
        ];
      }
    });
  }
}

Enter fullscreen mode Exit fullscreen mode

What's happening here?

  1. Signal-based model: We define our data structure as a signal
  2. Path-based validation: The form() function uses path-based validators
  3. Conditional validation: We use when: ({ valueOf }) for dynamic validation
  4. New control flow syntax: We use @if and @for instead of ngIf and ngFor
  5. Built-in submission handling: The submit() function manages loading states and errors automatically

Advanced Validation Patterns with Schema and Apply

One of the most powerful features of Signal-Based Forms is reusable validation schemas using schema() and apply():

Creating Reusable Schemas

import { schema, apply, form, required, minLength, email, pattern } from '@angular/forms/signals';

// Define reusable validation schemas
const nameSchema = schema<string>((path) => [
  required(path, { message: 'This field is required' }),
  minLength(path, 2, { message: 'Must be at least 2 characters' }),
  pattern(path, /^[a-zA-Z\s'-]+$/, {
    message: 'Only letters, spaces, hyphens and apostrophes allowed'
  })
]);

const emailSchema = schema<string>((path) => [
  required(path, { message: 'Email is required' }),
  email(path, { message: 'Please enter a valid email address' })
]);

const businessEmailSchema = schema<string>((path) => [
  required(path, { message: 'Business email is required' }),
  email(path, { message: 'Invalid email format' }),
  pattern(path, /^[a-zA-Z0-9._%+-]+@(?!gmail|yahoo|hotmail|outlook)[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/, {
    message: 'Business domain required'
  })
]);

// Apply schemas to form fields
registrationForm = form(this.userRegistration, (path) => [
  apply(path.personalInfo.firstName, nameSchema),
  apply(path.personalInfo.lastName, nameSchema),

  // Conditional schema application
  apply(path.personalInfo.email, businessEmailSchema, {
    when: ({ valueOf }) => valueOf(path.preferences.newsletter) === true
  }),

  apply(path.personalInfo.email, emailSchema, {
    when: ({ valueOf }) => valueOf(path.preferences.newsletter) === false
  }),

  // Additional conditional validation
  minLength(path.personalInfo.firstName, 5, {
    message: 'Business accounts require longer names',
    when: ({ valueOf }) => valueOf(path.accountType) === 'business'
  })
]);

Enter fullscreen mode Exit fullscreen mode

Schema Composition for Complex Validation

// Base schema for all text inputs
const baseTextSchema = schema<string>((path) => [
  required(path, { message: 'This field is required' }),
  minLength(path, 1, { message: 'Field cannot be empty' })
]);

// Extended schema for names
const enhancedNameSchema = schema<string>((path) => [
  ...baseTextSchema(path), // Spread base validation
  maxLength(path, 50, { message: 'Name cannot exceed 50 characters' }),
  pattern(path, /^[a-zA-Z\s'-]+$/, { message: 'Invalid characters detected' })
]);

// Usage in form with complex conditions
registrationForm = form(this.userRegistration, (path) => [
  apply(path.personalInfo.firstName, enhancedNameSchema),
  apply(path.personalInfo.lastName, enhancedNameSchema),

  // Multiple conditional validations
  required(path.personalInfo.email, {
    message: 'Email required when notifications are enabled',
    when: ({ valueOf }) => valueOf(path.preferences.notifications) === true
  }),

  pattern(path.personalInfo.email, /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/, {
    message: 'Personal email format is invalid',
    when: ({ valueOf }) => valueOf(path.accountType) === 'personal'
  }),

  pattern(path.personalInfo.email, /^[a-zA-Z0-9._%+-]+@(?!gmail|yahoo|hotmail|outlook)[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/, {
    message: 'Business email required for business accounts',
    when: ({ valueOf }) => valueOf(path.accountType) === 'business'
  })
]);

Enter fullscreen mode Exit fullscreen mode

Built-in Submission Handling with submit()

The submit() function is a game-changer for handling form submissions. It automatically manages loading states and error handling:

import { submit } from '@angular/forms/signals';

async onSubmit() {
  await submit(this.registrationForm, async () => {
    try {
      const response = await fetch('/api/users', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify(this.registrationForm.value())
      });

      if (!response.ok) {
        const errorData = await response.json();

        // Map server errors to specific fields
        return [
          {
            kind: 'server',
            message: 'This email is already registered',
            field: this.registrationForm.personalInfo.email
          }
        ];
      }

      // Success - return undefined
      return undefined;

    } catch (error) {
      // Global error
      return [
        { kind: 'server', message: 'Network error. Please try again.' }
      ];
    }
  });
}

Enter fullscreen mode Exit fullscreen mode

Template Integration with Submission States

The submit() function automatically provides these signals:

  • submitting() → true while request is in progress
  • submitError() → contains error information if submission fails
  • submitSuccess() → indicates successful submission
<button
  type="submit"
  [disabled]="registrationForm.submitting() || !registrationForm.valid()"
>
  @if (registrationForm.submitting()) {
    <span>Creating Account...</span>
  } @else {
    <span>Create Account</span>
  }
</button>

Enter fullscreen mode Exit fullscreen mode

Advanced Angular Features: Dynamic Forms and Runtime Controls

Here are some exciting patterns that Signal-Based Forms enable:

Runtime Creation and Removal of Form Controls

@Component({
  selector: 'app-dynamic-form-builder',
  template: `
    <div class="form-builder">
      <h2>Dynamic Form Builder</h2>

      <div class="control-types">
        <button (click)="addField('text')">Add Text Field</button>
        <button (click)="addField('email')">Add Email Field</button>
        <button (click)="addField('select')">Add Select Field</button>
      </div>

      <form (ngSubmit)="onSubmit()">
        @for (field of formFields(); track field.id) {
          <div class="dynamic-field">
            <div class="field-header">
              <span>{{ field.label }}</span>
              <button type="button" (click)="removeField(field.id)">Remove</button>
            </div>

            @switch (field.type) {
              @case ('text') {
                <input
                  type="text"
                  [control]="getFieldControl(field.id)"
                  [placeholder]="field.placeholder || ''"
                />
              }
              @case ('email') {
                <input
                  type="email"
                  [control]="getFieldControl(field.id)"
                  placeholder="Enter email"
                />
              }
              @case ('select') {
                <select [control]="getFieldControl(field.id)">
                  <option value="">Choose...</option>
                  @for (option of field.options; track option.value) {
                    <option [value]="option.value">{{ option.label }}</option>
                  }
                </select>
              }
            }

            @if (getFieldControl(field.id).errors(); as errors) {
              @for (error of errors; track error.code) {
                <span class="error">{{ error.message }}</span>
              }
            }
          </div>
        }

        @if (formFields().length > 0) {
          <button
            type="submit"
            [disabled]="!dynamicForm.valid() || dynamicForm.submitting()"
          >
            Submit
          </button>
        }
      </form>
    </div>
  `
})
export class DynamicFormBuilderComponent {
  private fieldCounter = 0;

  formFields = signal<Array<{
    id: string;
    type: 'text' | 'email' | 'select';
    label: string;
    placeholder?: string;
    options?: Array<{ value: string; label: string }>;
  }>>([]);

  // Create form data structure reactively
  formData = computed(() => {
    const data: Record<string, any> = {};
    this.formFields().forEach(field => {
      data[field.id] = '';
    });
    return data;
  });

  // Create dynamic form with reactive validation
  dynamicForm = computed(() => {
    const dataSignal = signal(this.formData());

    return form(dataSignal, (path) => {
      return this.formFields().map(field => {
        const fieldPath = path[field.id];
        const validators = [];

        validators.push(
          required(fieldPath, {
            message: `${field.label} is required`
          })
        );

        if (field.type === 'email') {
          validators.push(
            email(fieldPath, {
              message: 'Please enter a valid email address'
            })
          );
        }

        return validators;
      }).flat();
    });
  });

  addField(type: 'text' | 'email' | 'select') {
    this.fieldCounter++;
    const id = `field_${this.fieldCounter}`;

    let newField;
    switch (type) {
      case 'text':
        newField = {
          id,
          type,
          label: `Text Field ${this.fieldCounter}`,
          placeholder: 'Enter text...'
        };
        break;
      case 'email':
        newField = {
          id,
          type,
          label: `Email Field ${this.fieldCounter}`
        };
        break;
      case 'select':
        newField = {
          id,
          type,
          label: `Select Field ${this.fieldCounter}`,
          options: [
            { value: 'option1', label: 'Option 1' },
            { value: 'option2', label: 'Option 2' }
          ]
        };
        break;
    }

    this.formFields.update(fields => [...fields, newField]);
  }

  removeField(fieldId: string) {
    this.formFields.update(fields =>
      fields.filter(field => field.id !== fieldId)
    );
  }

  getFieldControl(fieldId: string) {
    return this.dynamicForm()[fieldId];
  }

  async onSubmit() {
    await submit(this.dynamicForm(), async () => {
      console.log('Dynamic form data:', this.dynamicForm().value());
      return undefined;
    });
  }
}

Enter fullscreen mode Exit fullscreen mode

Signal-Powered Form Arrays

No more clunky FormArray - just use reactive arrays:

@Component({
  selector: 'app-contact-list',
  template: `
    <form>
      <h2>Emergency Contacts</h2>

      @for (contact of contactsData().contacts; track contact.id; let i = $index) {
        <fieldset class="contact-group">
          <legend>Contact {{ i + 1 }}</legend>

          <input
            [control]="contactsForm.contacts[i].name"
            placeholder="Name"
          />

          <input
            [control]="contactsForm.contacts[i].phone"
            placeholder="Phone"
            type="tel"
          />

          <button
            type="button"
            (click)="removeContact(i)"
            [disabled]="contactsData().contacts.length <= 1"
          >
            Remove
          </button>
        </fieldset>
      }

      <button type="button" (click)="addContact()">Add Contact</button>
      <button type="submit" (click)="onSubmit()">Save Contacts</button>
    </form>
  `
})
export class ContactListComponent {
  contactsData = signal({
    contacts: [
      { id: '1', name: '', phone: '' }
    ]
  });

  contactsForm = form(this.contactsData, (path) => [
    // Validate each contact in the array
    ...this.contactsData().contacts.map((_, index) => [
      required(path.contacts[index].name, {
        message: 'Contact name is required'
      }),
      required(path.contacts[index].phone, {
        message: 'Phone number is required'
      }),
      pattern(path.contacts[index].phone, /^\+?[\d\s-()]+$/, {
        message: 'Invalid phone number format'
      })
    ]).flat()
  ]);

  addContact() {
    this.contactsData.update(data => ({
      ...data,
      contacts: [
        ...data.contacts,
        { id: crypto.randomUUID(), name: '', phone: '' }
      ]
    }));
  }

  removeContact(index: number) {
    this.contactsData.update(data => ({
      ...data,
      contacts: data.contacts.filter((_, i) => i !== index)
    }));
  }

  async onSubmit() {
    await submit(this.contactsForm, async () => {
      console.log('Contacts:', this.contactsForm.value());
      return undefined;
    });
  }
}

// one other way to do like 

import { form, FormArray, control } from '@angular/forms/signals';
import { signal } from '@angular/core';

export class SkillsFormComponent {
  // Step 1: Create a signal for initial skills list
  public skillsSignal = signal([ 'Angular', 'TypeScript' ]);
  // Step 2: Create a FormArray from the signal
  public skillsForm = new FormArray(
    this.skillsSignal().map(skill => control(skill))
  );
  addSkill(newSkill: string) {
    this.skillsForm.push(control(newSkill));
  }
  removeSkill(index: number) {
    this.skillsForm.removeAt(index);
  }
}
Enter fullscreen mode Exit fullscreen mode

Available Form Properties

Signal Forms exposes several reactive properties you can subscribe to directly:

  • valid() - Whether the form is valid
  • invalid() - Whether the form has validation errors
  • pending() - Whether async validation is in progress
  • touched() - Whether any field has been touched
  • dirty() - Whether any field value has changed
  • errors() - Array of validation errors
  • submitting() - Whether form submission is in progress
  • submitError() - Error information from failed submission
  • value() - Current form value

Cross-Field Validation (Password Confirmation)

Validating that two fields match (like a password and its confirmation) is a classic case. With signal forms, we can inspect the whole form state in a validator:

@Component({
  // ...
})
export class ChangePasswordComponent {
  passwordData = signal({ password: '', confirm: '' });

  passwordForm = form(this.passwordData, (path) => [
    required(path.password, { message: 'Password is required' }),
    required(path.confirm, { message: 'Please confirm your password' }),

    // Cross-field validator: ensure both fields match
    validate(path, ({ value }) => {
      if (value.password !== value.confirm) {
        return [customError({
          kind: 'mismatch',
          message: 'Passwords do not match'
        })];
      }
      return []; // no errors
    })
  ]);
}

Enter fullscreen mode Exit fullscreen mode
<form>
  <label>
    Password:
    <input type="password" [control]="passwordForm.password" />
  </label>

  <label>
    Confirm:
    <input type="password" [control]="passwordForm.confirm" />
  </label>

  @if (passwordForm.errors().length) {
    <div class="error">{{ passwordForm.errors()[0].message }}</div>
  }
</form>

Enter fullscreen mode Exit fullscreen mode

Asynchronous Validation

Signal forms support async validators out-of-the-box. For example, checking if a username is already taken:

@Component({
  // ...
})
export class RegisterComponent {
  userData = signal({ username: '' });

  registrationForm = form(this.userData, (path) => [
    required(path.username, { message: 'Username required' }),

    // Async unique-check via HTTP
    validateHttp(path.username, {
      request: ({ value }) => {
        return value() ? `https://example.com/api/check/${value()}` : undefined;
      },
      // Parse the response and return errors if any
      errors: (res: any) => {
        return res && !res.unique
          ? [customError({ kind: 'notUnique', message: 'Username already taken' })]
          : [];
      }
    })
  ]);
}

Enter fullscreen mode Exit fullscreen mode
<form>
  <label>
    Username:
    <input [control]="registrationForm.username" />
  </label>

  @if (registrationForm.username.pending()) {
    <div>Checking...</div>
  }

  @if (registrationForm.username.invalid()) {
    <div>{{ registrationForm.username.errors()[0].message }}</div>
  }
</form>

Enter fullscreen mode Exit fullscreen mode

During the HTTP request, registrationForm.username.pending() is true, which we show as a "Checking..." indicator. The validateHttp function converts the HTTP call into a signal that updates once the fetch completes.

Dynamic Field Disabling

You can also disable fields dynamically based on form state:

@Component({
  // ...
})
export class ProfileFormComponent {
  profileData = signal({ firstName: '', lastName: '' });

  profileForm = form(this.profileData, (path) => [
    required(path.firstName, { message: 'First name is required' }),
    required(path.lastName, { message: 'Last name is required' }),

    // Disable lastName unless firstName is non-empty
    disabled(path.lastName, ({ valueOf }) => valueOf(path.firstName) === '')
  ]);
}

Enter fullscreen mode Exit fullscreen mode
<form>
  <label>First Name: <input [control]="profileForm.firstName" /></label>
  <label>Last Name: <input [control]="profileForm.lastName" /></label>
</form>

Enter fullscreen mode Exit fullscreen mode

Testing Signal-Based Forms

Testing is crucial, and Signal-Based Forms make it more intuitive:

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RegistrationComponent } from './registration.component';

describe('RegistrationComponent', () => {
  let component: RegistrationComponent;
  let fixture: ComponentFixture<RegistrationComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [RegistrationComponent]
    }).compileComponents();

    fixture = TestBed.createComponent(RegistrationComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  describe('Form Validation', () => {
    it('should require first name', () => {
      // Act
      component.registrationForm.personalInfo.firstName.setValue('');
      component.registrationForm.personalInfo.firstName.markAsTouched();

      // Assert
      expect(component.registrationForm.personalInfo.firstName.valid()).toBe(false);
      expect(component.registrationForm.personalInfo.firstName.errors()).toContain(
        jasmine.objectContaining({ message: 'First name is required' })
      );
    });

    it('should validate conditional email requirement', () => {
      // Arrange
      component.userRegistration.update(data => ({
        ...data,
        preferences: { ...data.preferences, notifications: true }
      }));

      // Act
      component.registrationForm.personalInfo.email.setValue('');

      // Assert
      expect(component.registrationForm.personalInfo.email.valid()).toBe(false);
      const errors = component.registrationForm.personalInfo.email.errors();
      expect(errors.some(e => e.message === 'Email required when notifications are enabled')).toBe(true);
    });
  });

  describe('Form Submission', () => {
    it('should handle successful submission', async () => {
      // Arrange
      const validUserData = {
        personalInfo: {
          firstName: 'John',
          lastName: 'Doe',
          email: 'john.doe@company.com'
        },
        preferences: {
          newsletter: true,
          notifications: true
        },
        accountType: 'business' as const,
        password: 'password123',
        confirmPassword: 'password123'
      };

      component.userRegistration.set(validUserData);
      spyOn(window, 'fetch').and.returnValue(
        Promise.resolve(new Response(JSON.stringify({ success: true }), { status: 200 }))
      );

      // Act
      await component.onSubmit();

      // Assert
      expect(window.fetch).toHaveBeenCalledWith('/api/users', jasmine.any(Object));
    });

    it('should handle submission errors with field mapping', async () => {
      // Arrange
      spyOn(window, 'fetch').and.returnValue(
        Promise.resolve(new Response('{"error": "Email exists"}', { status: 400 }))
      );

      // Act
      await component.onSubmit();

      // Assert
      expect(component.registrationForm.submitError()).toBeTruthy();
    });
  });

  describe('Conditional Validation', () => {
    it('should only require business email format when newsletter is enabled', () => {
      // Arrange - enable newsletter
      component.userRegistration.update(data => ({
        ...data,
        preferences: { ...data.preferences, newsletter: true }
      }));

      // Act - set gmail address
      component.registrationForm.personalInfo.email.setValue('test@gmail.com');

      // Assert - should fail business email validation
      const errors = component.registrationForm.personalInfo.email.errors();
      expect(errors.some(e => e.message.includes('Business email required'))).toBe(true);
    });

    it('should accept gmail when newsletter is disabled', () => {
      // Arrange - disable newsletter
      component.userRegistration.update(data => ({
        ...data,
        preferences: { ...data.preferences, newsletter: false }
      }));

      // Act - set gmail address
      component.registrationForm.personalInfo.email.setValue('test@gmail.com');

      // Assert - should pass validation
      const errors = component.registrationForm.personalInfo.email.errors();
      expect(errors.some(e => e.message.includes('Business email required'))).toBe(false);
    });
  });
});

Enter fullscreen mode Exit fullscreen mode

Performance Considerations and Best Practices

Signal-Based Forms are built with performance in mind, but there are still best practices to follow:

1. Use Computed Signals for Derived State

export class ProfileComponent {
  user = signal({
    firstName: '',
    lastName: '',
    email: '',
    birthDate: ''
  });

  profileForm = form(this.user, (path) => [
    apply(path.firstName, nameSchema),
    apply(path.lastName, nameSchema),
    apply(path.email, emailSchema)
  ]);

  // Derived state using computed signals
  fullName = computed(() => {
    const user = this.user();
    return `${user.firstName} ${user.lastName}`.trim();
  });

  isAdult = computed(() => {
    const birthDate = new Date(this.user().birthDate);
    const today = new Date();
    const age = today.getFullYear() - birthDate.getFullYear();
    return age >= 18;
  });

  // Conditional validation based on computed state
  ageRestrictedForm = form(this.user, (path) => [
    required(path.email, {
      message: 'Email required for adult accounts',
      when: () => this.isAdult()
    })
  ]);
}

Enter fullscreen mode Exit fullscreen mode

2. Debounce Expensive Validations

import { debounce } from 'lodash-es';

const expensiveAsyncValidator = debounce(async (value: string) => {
  if (!value) return null;

  const result = await this.expensiveValidationService.validate(value);
  return result.isValid ? null : {
    expensive: { message: result.errorMessage }
  };
}, 500);

// Use in form validation
registrationForm = form(this.userRegistration, (path) => [
  required(path.personalInfo.email, { message: 'Email is required' }),
  validateAsync(path.personalInfo.email, expensiveAsyncValidator)
]);

Enter fullscreen mode Exit fullscreen mode

3. Use Schema Functions for Reusable Validation Logic

// validators/schemas.ts
import { schema, apply } from '@angular/forms/signals';

export const personNameSchema = schema<string>((path) => [
  required(path, { message: 'This field is required' }),
  minLength(path, 2, { message: 'Must be at least 2 characters' }),
  pattern(path, /^[a-zA-Z\s'-]+$/, {
    message: 'Only letters, spaces, hyphens and apostrophes allowed'
  })
]);

export const strongPasswordSchema = schema<string>((path) => [
  required(path, { message: 'Password is required' }),
  minLength(path, 8, { message: 'Password must be at least 8 characters' }),
  pattern(path, /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, {
    message: 'Password must contain uppercase, lowercase, and number'
  })
]);

// In your component
registrationForm = form(this.userRegistration, (path) => [
  apply(path.personalInfo.firstName, personNameSchema),
  apply(path.personalInfo.lastName, personNameSchema),
  apply(path.password, strongPasswordSchema)
]);

Enter fullscreen mode Exit fullscreen mode

When to Choose Signal-Based Forms

Here's my decision framework for when to adopt Signal-Based Forms:

Choose Signal-Based Forms when:

  • Building new applications or features
  • Working with complex conditional validation
  • Need fine-grained reactivity
  • Want to reduce boilerplate code
  • Team is comfortable with modern Angular patterns
  • Working with dynamic forms

Stick with Reactive Forms when:

  • Working with legacy codebases
  • Need battle-tested stability for critical applications
  • Team prefers traditional approaches
  • Using third-party form libraries that expect Reactive Forms
  • Tight project deadlines (experimental features carry risk)

Avoid Template-Driven Forms when:

  • Building anything beyond simple contact forms
  • Need complex validation logic
  • Want type safety
  • Working on enterprise applications

Production-Ready Patterns

Here are some patterns for using Signal-Based Forms in production:

1. Create Form Factories for Complex Forms

@Injectable({
  providedIn: 'root'
})
export class FormFactoryService {
  createUserRegistrationForm(initialData?: Partial<UserRegistration>) {
    const defaultData: UserRegistration = {
      personalInfo: { firstName: '', lastName: '', email: '' },
      preferences: { newsletter: false, notifications: true },
      accountType: 'personal',
      password: '',
      confirmPassword: ''
    };

    const userData = signal({ ...defaultData, ...initialData });

    return form(userData, (path) => [
      apply(path.personalInfo.firstName, personNameSchema),
      apply(path.personalInfo.lastName, personNameSchema),
      apply(path.personalInfo.email, emailSchema),

      required(path.personalInfo.email, {
        message: 'Email required when notifications are enabled',
        when: ({ valueOf }) => valueOf(path.preferences.notifications) === true
      }),

      apply(path.password, strongPasswordSchema),
      required(path.confirmPassword, {
        message: 'Please confirm your password',
        when: ({ valueOf }) => valueOf(path.password) !== ''
      })
    ]);
  }
}

Enter fullscreen mode Exit fullscreen mode

2. Handle Server-Side Validation Errors

async onSubmit() {
  await submit(this.registrationForm, async () => {
    try {
      const response = await this.userService.register(this.registrationForm.value());
      return undefined; // Success
    } catch (error) {
      if (error instanceof HttpErrorResponse && error.status === 400) {
        // Map server validation errors to form fields
        const serverErrors = error.error.errors;
        return Object.keys(serverErrors).map(field => ({
          kind: 'server' as const,
          message: serverErrors[field][0],
          field: this.getFormFieldPath(field)
        }));
      }

      return [
        {
          kind: 'server' as const,
          message: 'Registration failed. Please try again.'
        }
      ];
    }
  });
}

private getFormFieldPath(serverField: string) {
  const fieldMapping: { [key: string]: any } = {
    'first_name': this.registrationForm.personalInfo.firstName,
    'last_name': this.registrationForm.personalInfo.lastName,
    'email': this.registrationForm.personalInfo.email
  };
  return fieldMapping[serverField];
}

Enter fullscreen mode Exit fullscreen mode

3. Create Reusable Form Field Components

@Component({
  selector: 'app-form-field',
  template: `
    <div class="form-field" [class.has-error]="hasError()">
      <label [for]="fieldId()">{{ label() }}</label>
      <ng-content></ng-content>
      @if (hasError() && showErrors()) {
        @for (error of control().errors(); track error.code) {
          <span class="error-message">{{ error.message }}</span>
        }
      }
    </div>
  `,
  styles: [`
    .form-field { margin-bottom: 1rem; }
    .form-field.has-error input, .form-field.has-error select {
      border-color: #e53e3e;
    }
    .error-message {
      color: #e53e3e;
      font-size: 0.875rem;
      display: block;
      margin-top: 0.25rem;
    }
  `]
})
export class FormFieldComponent {
  label = input.required<string>();
  fieldId = input.required<string>();
  control = input.required<any>();
  showErrors = input(signal(true));

  hasError = computed(() =>
    this.control()?.errors()?.length > 0 &&
    (this.control()?.touched() || this.showErrors())
  );
}

Enter fullscreen mode Exit fullscreen mode

A tree of Field

Calling the form function gives the developer access to a Field tree. The form itself is a Field called the Root Field.

Field instance provides its state, which later allows you to retrieve its value, validity, and more and it can be retrieve by calling the Field function.

Let's illustrate that with a bit of code :)

interface Assigned {
  name: string;
  firstname: string;
}

interface Todo {
  title: string;
  description: string;
  status: TodoStatus;
  assigned: Assigned[]
}

@Component({
  selector: 'app-form',
  templateUrl: './app-form.html'
})
export class AppForm {
  todoModel = signal<Todo>({
    title: '',
    description: '',
    status: 'not_begin',
    assigned: []
  }); // We create the model that will be the source of truth for the form and it's tree field

  todoForm = form(this.todoModel); // we create the form which is linked to the model

  titleField: Field<string> = this.todoForm.title;
  firstAssigned: Field<Assigned> = this.todoForm.assigned[0];
  firstAssignedName: Field<string> = firstAssigned.name;

}

Enter fullscreen mode Exit fullscreen mode

Field Instance

As explained previously, a Field instance returns the state of that field. The state is composed of 6 main points.

  • value: A WritableSignal that allows you to read and write the value of a field.
  • errors: A signal for retrieving validation errors on the field.
  • valid: A signal for retrieving the field's validity.
  • disabled: A signal for retrieving whether the field is disabled.
  • touched: A signal to know if the user has interacted with the field or one of its children.
  • dirty: A signal to know if the field or one of its children is dirty.

For more details on what a field instance can offer, or to see the concrete implementation, please refer to the following

What's Coming Next in Angular Signal Forms?

The Angular team is actively working on Signal-Based Forms, and here's what we might see in future releases:

  • JSON Schema Integration → Generate forms from JSON schemas automatically
  • Better DevTools Integration → Debug signal forms visually
  • Performance Optimizations → Even faster updates for large forms
  • More Built-in Validators → Common validation patterns out of the box
  • Enhanced Accessibility → ARIA attributes and screen reader support
  • Form State Persistence → Automatic save/restore of form data

Keep an eye on the Angular blog and GitHub discussions for the latest updates!

Recap: Your Signal-Based Forms Journey

We've covered a lot of ground! Here's what you now know:

  1. Why signals matter for forms: reduced boilerplate, better reactivity, cleaner code
  2. Core APIs: form(), schema(), apply(), submit(), and conditional validation with when
  3. Real-world implementation with complex validation scenarios
  4. Testing strategies that work with signal-based architecture
  5. Performance patterns for production applications
  6. Dynamic form creation with runtime control management
  7. Advanced patterns like form arrays without FormArray

The key takeaway? Signal-Based Forms aren't just a new API—they represent a fundamental shift toward more declarative, reactive form handling. They eliminate much of the ceremony around form creation while providing powerful features for complex scenarios.

Remember: this is still experimental technology. Start with small experiments, learn the patterns, and be prepared for API changes as the feature evolves toward stable release.

What Did You Think?

I'm curious about your experience with Angular forms and your thoughts on this new approach. Have you hit similar frustrations with traditional form handling?

Drop a comment below and let me know:

  • Your biggest form-related pain point in current projects
  • Whether you're planning to experiment with Signal-Based Forms
  • Which pattern from this article you're most excited to try
  • Any specific scenarios you'd like me to explore in future articles

Your feedback helps me understand what resonates most with the Angular community!

Found This Helpful?

If this deep-dive saved you some research time or gave you new ideas for handling forms, hit that clap button! Your engagement helps other developers discover content like this.

I especially love hearing success stories—if you try any of these patterns in your projects, come back and share how it went.

Want More Angular Insights?

I regularly share advanced Angular patterns, performance tips, and deep-dives into new features. Follow me here on Medium to get notified when I publish:

  • Migration guides for new Angular features
  • Performance optimization techniques that actually work
  • Testing strategies for modern Angular applications
  • Real-world patterns from production applications

What Angular topic should I tackle next? I'm always looking for community input on what would be most valuable!


Action Points for Getting Started

Ready to experiment with Signal-Based Forms? Here's your roadmap:

This Week:

  1. Set up a new Angular project with the latest version
  2. Enable the experimental Signal Forms feature
  3. Build a simple login form to get familiar with the syntax

Next Week:

  1. Convert one existing Reactive Form to Signal-Based approach
  2. Experiment with conditional validation using when
  3. Try the schema() and apply() patterns for reusable validation

This Month:

  1. Build a complex form with dynamic fields
  2. Implement the submit() function with proper error handling
  3. Write comprehensive tests for your signal-based forms
  4. Share your learnings with your team

Ongoing:

  • Follow Angular's GitHub discussions for API updates
  • Experiment with advanced patterns as your confidence grows
  • Consider Signal-Based Forms for new feature development
  • Provide feedback to the Angular team on the experimental APIs

Remember: every expert was once a beginner. The best way to learn these new patterns is by building real applications. Start small, experiment often, and don't be afraid to make mistakes—that's how we all grow as developers!


The purpose of the article is to make you aware of signal form; it may have broken code, or something may not work.

References: 

https://medium.com/@Angular_With_Awais/goodbye-boilerplate-are-signal-forms-the-future-of-angular-21-970679d17889

https://medium.com/mustakbil/angular-signal-forms-deep-dive-build-smarter-forms-with-new-signal-forms-api-in-angular-21-1feb15a68403

https://medium.com/@vetriselvan_11/getting-started-with-signal-forms-in-angular-v21-part-1-13212078820d

https://javascript.plainenglish.io/angular-signal-forms-are-out-experimentally-4257782191bb


🎯 Your Turn, Devs!

👀 Did this article spark new ideas or help solve a real problem?

💬 I'd love to hear about it!

✅ Are you already using this technique in your Angular or frontend project?

🧠 Got questions, doubts, or your own twist on the approach?

Drop them in the comments below — let’s learn together!


🙌 Let’s Grow Together!

If this article added value to your dev journey:

🔁 Share it with your team, tech friends, or community — you never know who might need it right now.

📌 Save it for later and revisit as a quick reference.


🚀 Follow Me for More Angular & Frontend Goodness:

I regularly share hands-on tutorials, clean code tips, scalable frontend architecture, and real-world problem-solving guides.

  • 💼 LinkedIn — Let’s connect professionally
  • 🎥 Threads — Short-form frontend insights
  • 🐦 X (Twitter) — Developer banter + code snippets
  • 👥 BlueSky — Stay up to date on frontend trends
  • 🌟 GitHub Projects — Explore code in action
  • 🌐 Website — Everything in one place
  • 📚 Medium Blog — Long-form content and deep-dives
  • 💬 Dev Blog — Free Long-form content and deep-dives
  • ✉️ Substack — Weekly frontend stories & curated resources
  • 🧩 Portfolio — Projects, talks, and recognitions
  • ✍️ Hashnode — Developer blog posts & tech discussions

🎉 If you found this article valuable:

  • Leave a 👏 Clap
  • Drop a 💬 Comment
  • Hit 🔔 Follow for more weekly frontend insights

Let’s build cleaner, faster, and smarter web apps — together.

Stay tuned for more Angular tips, patterns, and performance tricks! 🧪🧠🚀

✨ Share Your Thoughts To 📣 Set Your Notification Preference

Top comments (0)