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:
initialUserDataSignal: Thissignalholds the initial values for our form. Its typeUserRegistrationis used byform()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.pathis an object that mirrors the structure of yourUserRegistrationinterface. 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/signalsand applied directly. You can pass amessageoption for custom error messages. - Custom Validators: We implement a custom validator for
confirmPasswordusingpath.confirmPassword.setValidators(). This checks ifconfirmPassword’s value matchespassword’s value. Custom validators should returnnullif valid, or an object ({ errorKey: 'message' }) if invalid.
isFormValidComputed: An example of howcomputedsignals automatically react to the form’s validity status.onSubmit(): Checksthis.registrationForm.valid()and then accesses the form’s value usingthis.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="[email protected]" />
<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! TheFieldDirectivecreates a two-way binding between the input and the corresponding signal form control. As you type, the signal updates.hasError(control)helper: We use thehasErrorfunction from the component to simplify error display logic: only show errors if the control isinvalid()ANDtouched().*ngFor="let error of registrationForm.controls.fieldName.errors()": Each control’serrors()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
passwordsMismatcherror, 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 ourisFormValidcomputed signal, which itself reacts to the form’s validity signal. This is a perfect example of signal-driven reactivity.
Step 5: Add Some Basic Styling (Optional but Recommended)
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
passwordandconfirmPasswordand observe the mismatch error. - Enter matching passwords.
- See how
Form StatusandForm Valueupdate in real-time below the form.
Mini-Challenge: Enhance Validation
- Add MinLength Validation for Email: Currently,
emailonly hasrequiredandemailformat validation. Add aminLengthvalidator to the email field, e.g., requiring at least 5 characters (to distinguish from a single@a.cinput). - Add a “Reset Form” Button: Implement a button that, when clicked, resets the form back to its
initialUserDatastate. (Hint: Look for areset()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/signalsis 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 theform()’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.