In the previous chapter, we got a conceptual overview of Signal Forms. Now, it’s time to put theory into practice. We’ll set up a simple user registration form using Signal Forms, focusing on field binding and basic validation.

Prerequisite: Ensure you have an Angular v21 project set up (e.g., using ng new your-app --standalone).

Step 1: Install Experimental Signal Forms Package

Since Signal Forms are experimental, they reside in a separate package (or subpath). You might need to install it explicitly or ensure your @angular/forms version includes it.

# This command might vary based on the exact v21 release and stability.
# If this doesn't work, refer to the official Angular v21 documentation for the correct installation method.
npm install @angular/forms@next

Or, if your project is already on v21.x.x-next, you might already have it. Just make sure the imports work.

Step 2: Create a User Registration Component

Let’s start by generating a new component for our registration form:

ng generate component user-registration --standalone --skip-tests

Step 3: Define the Data Model and Form Structure

Open src/app/user-registration/user-registration.component.ts. First, define an interface for our user data and then set up the signal for the initial form state.

// src/app/user-registration/user-registration.component.ts
import { Component, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';

// IMPORTANT: These imports are from the experimental signal forms package.
// Paths might change in stable releases.
import {
  form,
  required,
  email,
  minLength,
  FieldDirective,
  SignalForm,
  SignalControlStatus,
} from '@angular/forms/signals';

// Define the shape of our form data
interface UserRegistration {
  firstName: string;
  lastName: string;
  email: string;
  password: string;
  confirmPassword: string;
}

@Component({
  selector: 'app-user-registration',
  standalone: true,
  // Add CommonModule for ngIf, ngFor, etc. and FieldDirective for form binding
  imports: [CommonModule, FieldDirective],
  templateUrl: './user-registration.component.html',
  styleUrls: ['./user-registration.component.css'],
})
export class UserRegistrationComponent {
  // 1. Define a signal for the initial form data model
  private initialUserData = signal<UserRegistration>({
    firstName: '',
    lastName: '',
    email: '',
    password: '',
    confirmPassword: '',
  });

  // 2. Create the SignalForm instance
  // The second argument is a schema function where we define validators
  registrationForm: SignalForm<UserRegistration> = form(
    this.initialUserData,
    (path) => {
      // Define validation rules for each field
      required(path.firstName, { message: 'First name is required.' });
      minLength(path.firstName, 2, { message: 'First name must be at least 2 characters.' });

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

      required(path.email, { message: 'Email is required.' });
      email(path.email, { message: 'Enter a valid email address.' });

      required(path.password, { message: 'Password is required.' });
      minLength(path.password, 6, {
        message: (control) =>
          `Password needs ${6 - control.value().length} more characters.`,
      });

      required(path.confirmPassword, { message: 'Confirm password is required.' });
      // Custom validation for password matching (more advanced, but good to see)
      path.confirmPassword.setValidators((control) => {
        return control.value() === path.password.value()
          ? null
          : { passwordsMismatch: 'Passwords do not match.' };
      });
    }
  );

  // Optional: A computed signal to easily check if the form is valid
  isFormValid = computed(() => this.registrationForm.valid());

  onSubmit(): void {
    // 3. Handle form submission
    if (this.registrationForm.valid()) {
      console.log('Form Submitted Successfully!', this.registrationForm.value());
      alert(
        'Registration Successful!\n' +
          JSON.stringify(this.registrationForm.value(), null, 2)
      );
      // Here you would typically send the data to a backend server
      this.registrationForm.reset(); // Reset the form after submission
    } else {
      console.warn('Form has validation errors. Please correct them.');
      // Mark all controls as touched to display errors to the user
      this.registrationForm.markAllAsTouched();
    }
  }

  // Helper to check if a control has errors and has been touched
  hasError(control: SignalControlStatus): boolean {
    return control.invalid() && control.touched();
  }
}

Explanation:

  • initialUserData Signal: This signal holds the initial values for our form. Its type UserRegistration is used by form() for type inference.
  • form() function: This is the core of Signal Forms. It takes the initial data signal and a schema function.
  • Schema Function (path) => { ... }: This function is where you define all your validation rules.
    • path is an object that mirrors the structure of your UserRegistration interface. Each property (path.firstName, path.email, etc.) gives you access to the corresponding signal form control.
    • Built-in Validators: required(), email(), minLength() are imported from @angular/forms/signals and applied directly. You can pass a message option for custom error messages.
    • Custom Validators: We implement a custom validator for confirmPassword using path.confirmPassword.setValidators(). This checks if confirmPassword’s value matches password’s value. Custom validators should return null if valid, or an object ({ errorKey: 'message' }) if invalid.
  • isFormValid Computed: An example of how computed signals automatically react to the form’s validity status.
  • onSubmit(): Checks this.registrationForm.valid() and then accesses the form’s value using this.registrationForm.value(). Notice the .value() to get the signal’s current value. markAllAsTouched() is useful to reveal all errors to the user on submission attempt.

Step 4: Create the Component Template

Now, let’s build the HTML template (src/app/user-registration/user-registration.component.html) to bind our form controls.

<!-- src/app/user-registration/user-registration.component.html -->
<div class="registration-container">
  <h2>User Registration</h2>
  <form (ngSubmit)="onSubmit()">
    <div class="form-group">
      <label for="firstName">First Name</label>
      <!-- [field] directive for two-way binding -->
      <input id="firstName" type="text" [field]="registrationForm.controls.firstName" placeholder="John" />
      <div *ngIf="hasError(registrationForm.controls.firstName)" class="error-message">
        <span *ngFor="let error of registrationForm.controls.firstName.errors()">
          {{ error.message }}
        </span>
      </div>
    </div>

    <div class="form-group">
      <label for="lastName">Last Name</label>
      <input id="lastName" type="text" [field]="registrationForm.controls.lastName" placeholder="Doe" />
      <div *ngIf="hasError(registrationForm.controls.lastName)" class="error-message">
        <span *ngFor="let error of registrationForm.controls.lastName.errors()">
          {{ error.message }}
        </span>
      </div>
    </div>

    <div class="form-group">
      <label for="email">Email</label>
      <input id="email" type="email" [field]="registrationForm.controls.email" placeholder="john.doe@example.com" />
      <div *ngIf="hasError(registrationForm.controls.email)" class="error-message">
        <span *ngFor="let error of registrationForm.controls.email.errors()">
          {{ error.message }}
        </span>
      </div>
    </div>

    <div class="form-group">
      <label for="password">Password</label>
      <input id="password" type="password" [field]="registrationForm.controls.password" placeholder="Min 6 characters" />
      <div *ngIf="hasError(registrationForm.controls.password)" class="error-message">
        <span *ngFor="let error of registrationForm.controls.password.errors()">
          {{ error.message }}
        </span>
      </div>
    </div>

    <div class="form-group">
      <label for="confirmPassword">Confirm Password</label>
      <input id="confirmPassword" type="password" [field]="registrationForm.controls.confirmPassword" placeholder="Re-enter password" />
      <div *ngIf="hasError(registrationForm.controls.confirmPassword)" class="error-message">
        <span *ngFor="let error of registrationForm.controls.confirmPassword.errors()">
          {{ error.message }}
        </span>
      </div>
      <!-- Global form error for password mismatch, if it affects the whole form -->
      <div *ngIf="registrationForm.errors()?.['passwordsMismatch'] && registrationForm.touched()" class="error-message">
        <span>{{ registrationForm.errors()?.['passwordsMismatch'] }}</span>
      </div>
    </div>

    <button type="submit" [disabled]="!isFormValid()">Register</button>

    <div class="form-status">
      <p>Form Status: {{ registrationForm.status() }}</p>
      <p>Form Valid: {{ isFormValid() }}</p>
      <p>Form Value:</p>
      <pre>{{ registrationForm.value() | json }}</pre>
    </div>
  </form>
</div>

Explanation:

  • [field]="registrationForm.controls.fieldName": This is the magic! The FieldDirective creates a two-way binding between the input and the corresponding signal form control. As you type, the signal updates.
  • hasError(control) helper: We use the hasError function from the component to simplify error display logic: only show errors if the control is invalid() AND touched().
  • *ngFor="let error of registrationForm.controls.fieldName.errors()": Each control’s errors() signal provides an array of active validation errors, which we can iterate over to display messages.
  • Global Form Errors: The example includes displaying a global passwordsMismatch error, which is useful when an error relates to multiple fields or the form as a whole.
  • [disabled]="!isFormValid()": The submit button’s disabled state automatically updates based on our isFormValid computed signal, which itself reacts to the form’s validity signal. This is a perfect example of signal-driven reactivity.

Create src/app/user-registration/user-registration.component.css:

/* src/app/user-registration/user-registration.component.css */
.registration-container {
  max-width: 500px;
  margin: 40px auto;
  padding: 30px;
  border-radius: 8px;
  box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
  background-color: #ffffff;
  font-family: Arial, sans-serif;
}

h2 {
  text-align: center;
  color: #333;
  margin-bottom: 25px;
}

.form-group {
  margin-bottom: 20px;
}

.form-group label {
  display: block;
  margin-bottom: 8px;
  font-weight: bold;
  color: #555;
}

.form-group input {
  width: calc(100% - 20px); /* Account for padding */
  padding: 10px;
  border: 1px solid #ddd;
  border-radius: 5px;
  font-size: 16px;
  box-sizing: border-box; /* Include padding in width */
}

.form-group input:focus {
  border-color: #007bff;
  outline: none;
  box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}

.error-message {
  color: #dc3545;
  font-size: 0.85em;
  margin-top: 5px;
  font-weight: 500;
}

button {
  width: 100%;
  padding: 12px;
  background-color: #007bff;
  color: white;
  border: none;
  border-radius: 5px;
  font-size: 18px;
  cursor: pointer;
  transition: background-color 0.3s ease;
}

button:hover:not(:disabled) {
  background-color: #0056b3;
}

button:disabled {
  background-color: #cccccc;
  cursor: not-allowed;
}

.form-status {
  margin-top: 30px;
  padding: 15px;
  background-color: #f8f9fa;
  border: 1px solid #e2e6ea;
  border-radius: 5px;
  font-size: 0.9em;
  color: #343a40;
}

.form-status pre {
  white-space: pre-wrap;
  word-wrap: break-word;
  background-color: #e9ecef;
  padding: 10px;
  border-radius: 4px;
  overflow-x: auto;
}

Step 6: Integrate into AppComponent

Finally, add your new component to src/app/app.component.ts:

// src/app/app.component.ts
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterOutlet } from '@angular/router';
import { UserRegistrationComponent } from './user-registration/user-registration.component'; // Import it!

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule, RouterOutlet, UserRegistrationComponent], // Add to imports
  template: `
    <main>
      <app-user-registration></app-user-registration>
    </main>
    <router-outlet></router-outlet>
  `,
  styles: [`
    main {
      display: flex;
      justify-content: center;
      align-items: center;
      min-height: 100vh;
      background-color: #f0f2f5;
    }
  `]
})
export class AppComponent {
  title = 'angular-signal-forms-demo';
}

Step 7: Run the Application

ng serve

Navigate to http://localhost:4200. Interact with the form:

  • Try submitting an empty form.
  • Type into fields and see validation errors appear/disappear.
  • Enter different passwords for password and confirmPassword and observe the mismatch error.
  • Enter matching passwords.
  • See how Form Status and Form Value update in real-time below the form.

Mini-Challenge: Enhance Validation

  1. Add MinLength Validation for Email: Currently, email only has required and email format validation. Add a minLength validator to the email field, e.g., requiring at least 5 characters (to distinguish from a single @a.c input).
  2. Add a “Reset Form” Button: Implement a button that, when clicked, resets the form back to its initialUserData state. (Hint: Look for a reset() method on the form instance).

Summary/Key Takeaways

  • You’ve successfully implemented a basic user registration form using Angular v21’s experimental Signal Forms.
  • The form() function from @angular/forms/signals is used to define the form structure and its validators.
  • The [field] directive provides two-way binding between HTML inputs and signal form controls.
  • Validators like required(), email(), minLength(), and custom validators are defined declaratively within the form()’s schema function.
  • Form status and values (e.g., registrationForm.valid(), registrationForm.value(), control.errors()) are accessed as signals, offering powerful and efficient reactivity without manual subscriptions.
  • Remember, Signal Forms are experimental, but this hands-on experience gives you a solid foundation for when they become stable.

This practical exposure should give you a good feel for the advantages and the slightly different mental model required for Signal Forms. In the next chapter, we’ll shift gears to another significant update: Vitest becoming the new default testing framework.