Now that we can display our list of users, the next logical step is to allow adding new ones. This is a perfect opportunity to get hands-on with Angular v21’s experimental Signal Forms. We’ll create a UserFormComponent that lets users input details for a new user, validates the input, and then uses our UserService to persist the data.
Remember, Signal Forms are experimental, so the API might evolve, but this will give you valuable experience with this promising feature.
Step 1: Generate UserFormComponent
Let’s generate the component inside our features/users/components directory.
ng generate component features/users/components/user-form --standalone --skip-tests
Step 2: Implement UserFormComponent Logic
Open src/app/features/users/components/user-form/user-form.component.ts and add the following:
// src/app/features/users/components/user-form/user-form.component.ts
import { Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common'; // For NgIf, NgFor
import { UserService } from '../../../../core/services/user.service'; // Adjust path
import { NewUser } from '../../../../shared/models/user.interface'; // Adjust path
// IMPORTANT: Imports from the experimental signal forms package.
// Ensure you have these available in your @angular/forms installation.
import {
form,
required,
email,
minLength,
pattern,
FieldDirective,
SignalForm,
SignalControlStatus,
} from '@angular/forms/signals';
import { Subject, takeUntil } from 'rxjs'; // For component destruction cleanup
// Define the shape for our new user form
interface UserFormModel {
name: string;
email: string;
role: 'admin' | 'user' | 'guest'; // Roles match our User interface
}
@Component({
selector: 'app-user-form',
standalone: true,
imports: [CommonModule, FieldDirective], // FieldDirective is essential for Signal Forms binding
templateUrl: './user-form.component.html',
styleUrls: ['./user-form.component.css'],
})
export class UserFormComponent {
private userService = inject(UserService);
private destroy$ = new Subject<void>(); // For managing subscriptions cleanup
// Initial state for the new user form
private initialNewUser = signal<UserFormModel>({
name: '',
email: '',
role: 'user', // Default role
});
// Create the Signal Form instance
userForm: SignalForm<UserFormModel> = form(this.initialNewUser, (path) => {
// Name validation
required(path.name, { message: 'Name is required.' });
minLength(path.name, 3, {
message: (control) =>
`Name must be at least 3 characters. Current length: ${control.value().length}`,
});
// Ensure name doesn't contain numbers (example custom validation)
pattern(path.name, /^[^0-9]*$/, { message: 'Name cannot contain numbers.' });
// Email validation
required(path.email, { message: 'Email is required.' });
email(path.email, { message: 'Enter a valid email address.' });
// Role validation (optional, as it's a select, but good to ensure a valid option is chosen)
required(path.role, { message: 'Role is required.' });
});
// Signal for local feedback message
feedbackMessage = signal<string | null>(null);
feedbackIsError = signal(false);
// Helper to check if a control has errors and has been touched/dirty
hasError(control: SignalControlStatus): boolean {
// Only show errors if control is invalid AND (touched OR dirty)
return control.invalid() && (control.touched() || control.dirty());
}
onSubmit(): void {
if (this.userForm.valid()) {
// Form is valid, get the value
const newUser: NewUser = this.userForm.value();
// Clear any previous feedback
this.feedbackMessage.set(null);
this.feedbackIsError.set(false);
// Call the UserService to add the user
this.userService.addUser(newUser)
.pipe(takeUntil(this.destroy$)) // Ensure subscription is cleaned up
.subscribe({
next: (addedUser) => {
console.log('User added:', addedUser);
this.feedbackMessage.set(`User "${addedUser.name}" added successfully!`);
this.feedbackIsError.set(false);
this.userForm.reset(); // Reset form to initial state
},
error: (err) => {
console.error('Error adding user:', err);
this.feedbackMessage.set(`Failed to add user: ${err.message || 'Unknown error'}`);
this.feedbackIsError.set(true);
},
});
} else {
// Form is invalid, mark all controls as touched to display errors
console.warn('Form has validation errors.');
this.userForm.markAllAsTouched();
this.feedbackMessage.set('Please correct the form errors.');
this.feedbackIsError.set(true);
}
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}
Explanation:
UserFormModelInterface: Defines the structure of the data expected by our form.userForm: SignalForm<UserFormModel> = form(...): This is the Signal Form instantiation.this.initialNewUserprovides the initial values and type inference.- The schema function defines validation rules for
name,email, androleusingrequired,minLength,email, andpatternvalidators. Custom error messages are provided.
feedbackMessage,feedbackIsErrorSignals: Local signals to provide user feedback after form submission (success or failure).hasError()Helper: A utility function to simplify displaying validation messages in the template, only showing them if the control is invalid AND has been touched or made dirty.onSubmit():- Checks
this.userForm.valid(). - If valid, it extracts the form value (
this.userForm.value()) which is type-safe asNewUser. - Calls
userService.addUser()and subscribes.takeUntil(this.destroy$)is used for RxJS subscription cleanup. - Updates
feedbackMessageandfeedbackIsErrorbased on success or error. this.userForm.reset(): Resets the form to its initial state (values and validity).- If invalid, it calls
this.userForm.markAllAsTouched()to ensure all errors are visible.
- Checks
Step 3: Implement UserFormComponent Template
Now, let’s create src/app/features/users/components/user-form/user-form.component.html to build the form UI.
<!-- src/app/features/users/components/user-form/user-form.component.html -->
<div class="user-form-container">
<h3>Add New User</h3>
<form (ngSubmit)="onSubmit()">
<!-- Name Field -->
<div class="form-group">
<label for="name">Name</label>
<input id="name" type="text" [field]="userForm.controls.name" placeholder="Enter full name" />
@if (hasError(userForm.controls.name)) {
<div class="error-message">
@for (error of userForm.controls.name.errors(); track error.key) {
<span>{{ error.message }}</span>
}
</div>
}
</div>
<!-- Email Field -->
<div class="form-group">
<label for="email">Email</label>
<input id="email" type="email" [field]="userForm.controls.email" placeholder="Enter email address" />
@if (hasError(userForm.controls.email)) {
<div class="error-message">
@for (error of userForm.controls.email.errors(); track error.key) {
<span>{{ error.message }}</span>
}
</div>
}
</div>
<!-- Role Field -->
<div class="form-group">
<label for="role">Role</label>
<select id="role" [field]="userForm.controls.role">
<option value="user">User</option>
<option value="admin">Admin</option>
<option value="guest">Guest</option>
</select>
@if (hasError(userForm.controls.role)) {
<div class="error-message">
@for (error of userForm.controls.role.errors(); track error.key) {
<span>{{ error.message }}</span>
}
</div>
}
</div>
<!-- Form Feedback -->
@if (feedbackMessage()) {
<div class="form-feedback" [class.error]="feedbackIsError()">
<p>{{ feedbackMessage() }}</p>
</div>
}
<div class="form-actions">
<button type="submit" [disabled]="userForm.invalid() || userService.loadingUsers.getValue()">Add User</button>
<button type="button" (click)="userForm.reset()">Reset Form</button>
</div>
</form>
</div>
Explanation:
[field]="userForm.controls.fieldName": TheFieldDirectiveautomatically handles two-way binding for each input and select element.@if (hasError(userForm.controls.fieldName)) { ... }: Conditionally displays error messages using ourhasErrorhelper.@for (error of userForm.controls.fieldName.errors(); track error.key) { ... }: Iterates over the errors for each control to display their messages.selectElement: Theselectelement also works seamlessly with[field].- Feedback Messages: Displays success or error messages after form submission.
- Submit Button: Disabled if the form is
invalid()OR if theuserServiceisloadingUsers(preventing double submission while an API call is in flight). - Reset Button: Calls
userForm.reset()to clear the form.
Step 4: Add Basic Styling
Create src/app/features/users/components/user-form/user-form.component.css:
/* src/app/features/users/components/user-form/user-form.component.css */
.user-form-container {
max-width: 500px;
margin: 30px auto;
padding: 25px;
border-radius: 10px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
background-color: #fcfcfc;
font-family: Arial, sans-serif;
}
h3 {
text-align: center;
color: #333;
margin-bottom: 25px;
font-size: 1.6em;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: bold;
color: #555;
}
.form-group input,
.form-group select {
width: calc(100% - 22px); /* Account for padding and border */
padding: 10px;
border: 1px solid #ddd;
border-radius: 5px;
font-size: 16px;
box-sizing: border-box;
}
.form-group input:focus,
.form-group select: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;
}
.error-message span {
display: block; /* Each error on new line */
}
.form-feedback {
padding: 12px 15px;
border-radius: 5px;
margin-top: 20px;
font-size: 1em;
font-weight: bold;
}
.form-feedback p {
margin: 0;
}
.form-feedback:not(.error) {
background-color: #d4edda; /* Light green for success */
color: #155724;
border: 1px solid #c3e6cb;
}
.form-feedback.error {
background-color: #f8d7da; /* Light red for error */
color: #721c24;
border: 1px solid #f5c6cb;
}
.form-actions {
display: flex;
justify-content: space-between;
margin-top: 25px;
}
.form-actions button {
padding: 10px 20px;
border: none;
border-radius: 5px;
font-size: 1em;
cursor: pointer;
transition: background-color 0.3s ease;
}
.form-actions button[type="submit"] {
background-color: #28a745; /* Green for submit */
color: white;
}
.form-actions button[type="submit"]:hover:not(:disabled) {
background-color: #218838;
}
.form-actions button[type="submit"]:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
.form-actions button[type="button"] { /* For Reset button */
background-color: #6c757d; /* Grey for reset */
color: white;
}
.form-actions button[type="button"]:hover:not(:disabled) {
background-color: #5a6268;
}
Step 5: Integrate UserFormComponent into AppComponent
For testing purposes, let’s temporarily add UserFormComponent to app.component.ts, positioning it below the UserListComponent.
Open 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 { UserListComponent } from './features/users/components/user-list/user-list.component';
import { UserFormComponent } from './features/users/components/user-form/user-form.component'; // Import it!
@Component({
selector: 'app-root',
standalone: true,
imports: [CommonModule, RouterOutlet, UserListComponent, UserFormComponent], // Add UserFormComponent
template: `
<header class="app-header">
<h1>User Management Dashboard</h1>
</header>
<main>
<app-user-list></app-user-list>
<app-user-form></app-user-form> <!-- Add our user form component -->
</main>
<router-outlet></router-outlet>
`,
styles: [`
.app-header {
background-color: #007bff;
color: white;
padding: 20px;
text-align: center;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}
h1 {
margin: 0;
font-size: 2em;
}
main {
padding: 20px;
background-color: #f8f9fa;
min-height: calc(100vh - 80px);
}
`]
})
export class AppComponent {
title = 'user-management-app';
}
Step 6: Run and Test the Application
Ensure your json-server is running in one terminal:
npm run serve:json-api
And your Angular app in another:
ng serve
Open your browser to http://localhost:4200.
- Try adding a new user: Fill in the form.
- Test validation (empty fields, invalid email, name with numbers).
- Submit a valid user. You should see the user immediately appear in the
UserListComponent(thanks toBehaviorSubjectinUserService) and a success message.
- Test the Reset button: Click it to clear the form.
Mini-Challenge: Add a Custom Password Field (Conceptual)
While our User model doesn’t currently include a password, imagine it did.
- How would you extend the
UserFormModelto includepasswordandconfirmPassword? - What additional
form()validation rules would you add to ensure:- Password is required and has a minimum length (e.g., 6 characters).
confirmPasswordmatchespassword? (Hint: Review theUserRegistrationComponentexample from Chapter 6 for custom cross-field validation).
(This is a conceptual challenge, you don’t need to implement it fully, but think about how to apply what you’ve learned about Signal Forms validation.)
Summary/Key Takeaways
- We successfully built
UserFormComponentusing Angular v21’s experimental Signal Forms. - The
form()function allowed us to declaratively define the form’s structure and validation rules. - Built-in validators (
required,email,minLength,pattern) and custom validators were applied effectively. - The
[field]directive provided seamless two-way binding between the template and signal form controls. - Form submission logic correctly uses
userForm.valid()anduserForm.value(), and updates a local feedback message signal. userForm.reset()is used to clear the form after submission.- The form demonstrates how to integrate with the
UserServiceand respond to its loading/error states.
This experience with Signal Forms, even in its experimental state, showcases its potential for simpler, more type-safe, and reactive form management in Angular. In the next chapter, we’ll organize our components with routing to create a more structured application.