Exploring Angular Forms: A New Alternative with Signals

thisdotmedia_staff

This Dot Media

Posted on November 6, 2024

Exploring Angular Forms: A New Alternative with Signals

Exploring Angular Forms: A New Alternative with Signals

In the world of Angular, forms are essential for user interaction, whether you're crafting a simple login page or a more complex user profile interface. Angular traditionally offers two primary approaches: template-driven forms and reactive forms. In my previous series on Angular Reactive Forms, I explored how to harness reactive forms' power to manage complex logic, create dynamic forms, and build custom form controls.

A new tool for managing reactivity - signals - has been introduced in version 16 of Angular and has been the focus of Angular maintainers ever since, becoming stable with version 17. Signals allow you to handle state changes declaratively, offering an exciting alternative that combines the simplicity of template-driven forms with the robust reactivity of reactive forms. This article will examine how signals can add reactivity to both simple and complex forms in Angular.

Recap: Angular Forms Approaches

Before diving into the topic of enhancing template-driven forms with signals, let’s quickly recap Angular's traditional forms approaches:

  1. Template-Driven Forms: Defined directly in the HTML template using directives like ngModel, these forms are easy to set up and are ideal for simple forms. However, they may not provide the fine-grained control required for more complex scenarios.

    Here's a minimal example of a template-driven form:

    <form (ngSubmit)="onSubmit()">
      <label for="name">Name:</label>
      <input id="name" [(ngModel)]="name" name="name">
      <button type="submit">Submit</button>
    </form>
    
```typescript
import { Component } from '@angular/core';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html'
})
export class AppComponent {
  name = '';

  onSubmit() {
    console.log(this.name);
  }
}
```
Enter fullscreen mode Exit fullscreen mode
  1. Reactive Forms: Managed programmatically in the component class using Angular's FormGroup, FormControl, and FormArray classes; reactive forms offer granular control over form state and validation. This approach is well-suited for complex forms, as my previous articles on Angular Reactive Forms discussed.

    And here's a minimal example of a reactive form:

    import { Component } from '@angular/core';
    import { FormGroup, FormControl } from '@angular/forms';
    
    @Component({
      selector: 'app-root',
      templateUrl: './app.component.html'
    })
    export class AppComponent {
      form = new FormGroup({
        name: new FormControl('')
      });
    
      onSubmit() {
        console.log(this.form.value);
      }
    }
    
```html
<form [formGroup]="form" (ngSubmit)="onSubmit()">
  <label for="name">Name:</label>
  <input id="name" formControlName="name">
  <button type="submit">Submit</button>
</form>
```
Enter fullscreen mode Exit fullscreen mode

Introducing Signals as a New Way to Handle Form Reactivity

With the release of Angular 16, signals have emerged as a new way to manage reactivity. Signals provide a declarative approach to state management, making your code more predictable and easier to understand. When applied to forms, signals can enhance the simplicity of template-driven forms while offering the reactivity and control typically associated with reactive forms.

Let’s explore how signals can be used in both simple and complex form scenarios.

Example 1: A Simple Template-Driven Form with Signals

Consider a basic login form. Typically, this would be implemented using template-driven forms like this:

<!-- login.component.html -->
<form name="form" (ngSubmit)="onSubmit()">
  <label for="email">E-mail</label>
  <input type="email" id="email" [(ngModel)]="email" required email />
  <label for="password">Password</label>
  <input type="password" id="password" [(ngModel)]="password" required />
  <button type="submit">Login!</button>
</form>
Enter fullscreen mode Exit fullscreen mode
// login.component.ts
import { Component } from "@angular/core";

@Component({
  selector: "app-login",
  templateUrl: "./login.component.html",
})
export class LoginComponent {
  public email: string = "";
  public password: string = "";

  onSubmit() {
    console.log("Form submitted", { email: this.email, password: this.password });
  }
}
Enter fullscreen mode Exit fullscreen mode

This approach works well for simple forms, but by introducing signals, we can keep the simplicity while adding reactive capabilities:

// login.component.ts
import { Component, computed, signal } from "@angular/core";
import { FormsModule } from "@angular/forms";

@Component({
  selector: "app-login",
  standalone: true,
  templateUrl: "./login.component.html",
  imports: [FormsModule],
})
export class LoginComponent {
  // Define signals for form fields
  public email = signal("");
  public password = signal(""); // Define a computed signal for the form value

  public formValue = computed(() => {
    return {
      email: this.email(),
      password: this.password(),
    };
  });

  public isFormValid = computed(() => {
    return this.email().length > 0 && this.password().length > 0;
  });

  onSubmit() {
    console.log("Form submitted", this.formValue());
  }
}
Enter fullscreen mode Exit fullscreen mode
<!-- login.component.html -->
<form name="form" (ngSubmit)="onSubmit()">
  <label for="email">E-mail</label>
  <input type="email" id="email" name="email" [(ngModel)]="email" required email />
  <label for="password">Password</label>
  <input type="password" name="password" id="password" [(ngModel)]="password" required />
  <button type="submit">Login!</button>
</form>
Enter fullscreen mode Exit fullscreen mode

In this example, the form fields are defined as signals, allowing for reactive updates whenever the form state changes. The formValue signal provides a computed value that reflects the current state of the form. This approach offers a more declarative way to manage form state and reactivity, combining the simplicity of template-driven forms with the power of signals.

You may be tempted to define the form directly as an object inside a signal. While such an approach may seem more concise, typing into the individual fields does not dispatch reactivity updates, which is usually a deal breaker. Here’s an example StackBlitz with a component suffering from such an issue:

Therefore, if you'd like to react to changes in the form fields, it's better to define each field as a separate signal. By defining each form field as a separate signal, you ensure that changes to individual fields trigger reactivity updates correctly.

Example 2: A Complex Form with Signals

You may see little benefit in using signals for simple forms like the login form above, but they truly shine when handling more complex forms. Let's explore a more intricate scenario - a user profile form that includes fields like firstName, lastName, email, phoneNumbers, and address. The phoneNumbers field is dynamic, allowing users to add or remove phone numbers as needed.

Here's how this form might be defined using signals:

// user-profile.component.ts
import { JsonPipe } from "@angular/common";
import { Component, computed, signal } from "@angular/core";
import { FormsModule, Validators } from "@angular/forms";

@Component({
  standalone: true,
  selector: "app-user-profile",
  templateUrl: "./user-profile.component.html",
  styleUrls: ["./user-profile.component.scss"],
  imports: [FormsModule, JsonPipe],
})
export class UserProfileComponent {
  public firstName = signal("");
  public lastName = signal("");
  public email = signal(""); 
  // We need to use a signal for the phone numbers, so we get reactivity when typing in the input fields
  public phoneNumbers = signal([signal("")]);
  public street = signal("");
  public city = signal("");
  public state = signal("");
  public zip = signal("");

  public formValue = computed(() => {
    return {
      firstName: this.firstName(),
      lastName: this.lastName(),
      email: this.email(), // We need to do a little mapping here, so we get the actual value for the phone numbers
      phoneNumbers: this.phoneNumbers().map((phoneNumber) => phoneNumber()),
      address: {
        street: this.street(),
        city: this.city(),
        state: this.state(),
        zip: this.zip(),
      },
    };
  });

  public formValid = computed(() => {
    const { firstName, lastName, email, phoneNumbers, address } = this.formValue(); // Regex taken from the Angular email validator

    const EMAIL_REGEXP = /^(?=.{1,254}$)(?=.{1,64}@)[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
    const isEmailFormatValid = EMAIL_REGEXP.test(email);

    return (
      firstName.length > 0 &&
      lastName.length > 0 &&
      email.length > 0 &&
      isEmailFormatValid &&
      phoneNumbers.length > 0 && // Check if all phone numbers are valid
      phoneNumbers.every((phoneNumber) => phoneNumber.length > 0) &&
      address.street.length > 0 &&
      address.city.length > 0 &&
      address.state.length > 0 &&
      address.zip.length > 0
    );
  });

  addPhoneNumber() {
    this.phoneNumbers.update((phoneNumbers) => {
      phoneNumbers.push(signal(""));
      return [...phoneNumbers];
    });
  }

  removePhoneNumber(index: number) {
    this.phoneNumbers.update((phoneNumbers) => {
      phoneNumbers.splice(index, 1);
      return [...phoneNumbers];
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice that the phoneNumbers field is defined as a signal of an array of signals. This structure allows us to track changes to individual phone numbers and update the form state reactively. The addPhoneNumber and removePhoneNumber methods update the phoneNumbers signal array, triggering reactivity updates in the form.

<!-- user-profile.component.html -->
<form class="form">
  <label for="firstName">First Name</label> <input type="text" id="firstName" name="firstName" [(ngModel)]="firstName" required />

  <label for="lastName">Last Name</label> <input type="text" id="lastName" name="lastName" [(ngModel)]="lastName" required />

  <label for="email">Email</label> <input type="email" id="email" name="emailAddress" [(ngModel)]="email" required email />

  <div id="phoneNumbers">
    <label>Phone Numbers</label> @for (phone of phoneNumbers(); track i; let i = $index) {
    <div><input type="tel" name="phoneNumbers-{{ i }}" [(ngModel)]="phone" required /> <button type="button" (click)="removePhoneNumber(i)">Remove</button></div>
    } <button type="button" (click)="addPhoneNumber()">Add Phone Number</button>
  </div>

  <label for="street">Street</label> <input type="text" id="street" name="street" [(ngModel)]="street" required />

  <label for="city">City</label> <input type="text" id="city" name="city" [(ngModel)]="city" required />

  <label for="state">State</label> <input type="text" id="state" name="state" [(ngModel)]="state" required />

  <label for="zip">ZIP Code</label> <input type="text" id="zip" name="zip" [(ngModel)]="zip" required />

  @if(!formValid()) {
  <div class="message message--error">Form is invalid!</div>
  } @else {
  <div class="message message--success">Form is valid!</div>
  }
  <pre>
    {{ formValue() | json }}
  </pre>
</form>
Enter fullscreen mode Exit fullscreen mode

In the template, we use the phoneNumbers signal array to dynamically render the phone number input fields. The addPhoneNumber and removePhoneNumber methods allow users to reactively add or remove phone numbers, updating the form state. Notice the usage of the track function, which is necessary to ensure that the ngFor directive tracks changes to the phoneNumbers array correctly.

Here's a StackBlitz demo of the complex form example for you to play around with:

Validating Forms with Signals

Validation is critical to any form, ensuring that user input meets the required criteria before submission. With signals, validation can be handled in a reactive and declarative manner. In the complex form example above, we've implemented a computed signal called formValid, which checks whether all fields meet specific validation criteria.

The validation logic can easily be customized to accommodate different rules, such as checking for valid email formats or ensuring that all required fields are filled out. Using signals for validation allows you to create more maintainable and testable code, as the validation rules are clearly defined and react automatically to changes in form fields. It can even be abstracted into a separate utility to make it reusable across different forms.

In the complex form example, the formValid signal ensures that all required fields are filled and validates the email and phone numbers format.

This approach to validation is a bit simple and needs to be better connected to the actual form fields. While it will work for many use cases, in some cases, you might want to wait until explicit "signal forms" support is added to Angular. Tim Deschryver started implementing some abstractions around signal forms, including validation and wrote an article about it. Let's see if something like this will be added to Angular in the future.

Why Use Signals in Angular Forms?

The adoption of signals in Angular provides a powerful new way to manage form state and reactivity. Signals offer a flexible, declarative approach that can simplify complex form handling by combining the strengths of template-driven forms and reactive forms. Here are some key benefits of using signals in Angular forms:

  1. Declarative State Management: Signals allow you to define form fields and computed values declaratively, making your code more predictable and easier to understand.

  2. Reactivity: Signals provide reactive updates to form fields, ensuring that changes to the form state trigger reactivity updates automatically.

  3. Granular Control: Signals allow you to define form fields at a granular level, enabling fine-grained control over form state and validation.

  4. Dynamic Forms: Signals can be used to create dynamic forms with fields that can be added or removed dynamically, providing a flexible way to handle complex form scenarios.

  5. Simplicity: Signals can offer a simpler, more concise way to manage form states than traditional reactive forms, making building and maintaining complex forms easier.

Conclusion

In my previous articles, we explored the powerful features of Angular reactive forms, from dynamic form construction to custom form controls. With the introduction of signals, Angular developers have a new tool that merges the simplicity of template-driven forms with the reactivity of reactive forms.

While many use cases warrant Reactive Forms, signals provide a fresh, powerful alternative for managing form state in Angular applications requiring a more straightforward, declarative approach. As Angular continues to evolve, experimenting with these new features will help you build more maintainable, performant applications.

Happy coding!

💖 💪 🙅 🚩
thisdotmedia_staff
This Dot Media

Posted on November 6, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related