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:

  • UserFormModel Interface: Defines the structure of the data expected by our form.
  • userForm: SignalForm<UserFormModel> = form(...): This is the Signal Form instantiation.
    • this.initialNewUser provides the initial values and type inference.
    • The schema function defines validation rules for name, email, and role using required, minLength, email, and pattern validators. Custom error messages are provided.
  • feedbackMessage, feedbackIsError Signals: 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 as NewUser.
    • Calls userService.addUser() and subscribes. takeUntil(this.destroy$) is used for RxJS subscription cleanup.
    • Updates feedbackMessage and feedbackIsError based 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.

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": The FieldDirective automatically handles two-way binding for each input and select element.
  • @if (hasError(userForm.controls.fieldName)) { ... }: Conditionally displays error messages using our hasError helper.
  • @for (error of userForm.controls.fieldName.errors(); track error.key) { ... }: Iterates over the errors for each control to display their messages.
  • select Element: The select element 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 the userService is loadingUsers (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 to BehaviorSubject in UserService) 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.

  1. How would you extend the UserFormModel to include password and confirmPassword?
  2. What additional form() validation rules would you add to ensure:
    • Password is required and has a minimum length (e.g., 6 characters).
    • confirmPassword matches password? (Hint: Review the UserRegistrationComponent example 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 UserFormComponent using 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() and userForm.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 UserService and 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.