Angular Signal Forms - Part 2

Welcome back to our series on Angular Signal Forms! In Part 1, we covered the basics: creating forms, handling submissions, and adding validation.

In this second part, we'll explore all the advanced features: custom validators, cross-field validation, asynchronous validation, dynamic field behavior, debouncing form updates, custom form components, sub forms, and other powerful capabilities that make Signal Forms a compelling choice for complex form scenarios.

Angular logo

Custom validation with validate() and validateTree()

You can write your own custom validators, either synchronous or asynchronous, and apply them with validate().

Let's start with a synchronous validator, that checks that the password of our Register form is strong enough.

A validator is simply a function that takes a ChildFieldContext as an argument, and returns either undefined if the field is valid, or a ValidationError if the field is invalid.

The ChildFieldContext provides useful methods to get the value or state of the field (as well as the value or state of other fields, more on that later).

Validators are typed, which is a great improvement over previous form systems.

// password is strong enough
validate(form.password, (context: ChildFieldContext<string>) =>
  isTooWeak(context.value()) ? { kind: 'too-weak', message: 'Password is too weak' } : undefined
);

It is also possible to add cross-field validation, by adding the validator to the parent field, and checking the values of the sub-fields.

// password is different from email
validate(form, context => {
  const { email, password } = context.value();
  return email && password && email === password
    ? {
        kind: 'password-different-from-email',
        message: 'Password should not be the same as email'
      }
    : undefined;
});

In your template, you can display this cross-field validation error by checking the errors on the form itself:

@if (accountForm().touched() && !accountForm().valid()) {
  <div id="form-errors">
    @for (error of accountForm().errors(); track error.kind) {
      <div>{{ error.message }}</div>
    }
  </div>
}

Alternatively, you can put the cross-field validation error on a specific field, using validateTree() instead of validate(). This function works like validate(), but allows returning errors targeting specific sub-fields of the tree. For example, to validate that the password is different from the email, but show the error on the password field:

validateTree(form, context => {
  const { email, password } = context.value();
  return email && password && email === password
    ? {
        // 👇 targets the password field
        field: context.field.password,
        kind: 'password-different-from-email',
        message: 'Password should not be the same as email'
      }
    : undefined;
});

It is also possible to add a validation to a field and get the value of another field by using the valueOf() method. The same example can be written like this:

// 👇 the validator is defined on the password field
validate(form.password, context => {
  // 👇 get the value of the email field
  const email = context.valueOf(form.email);
  const password = context.value();
  return email && password && email === password
    ? {
        kind: 'is-different-from-email',
        message: 'Password should not be the same as email'
      }
    : undefined;
});

Extracting and applying schemas with apply()

To avoid duplicating validation logic across forms, you can extract it into reusable schemas, that can then be applied to fields using the apply() function.

If both our login and password fields should be required and with a minimum length, we can extract this logic into a schema:

const requiredAndMinLengthSchema = schema<string>(field => {
  required(field, { message: 'This field is required' });
  minLength(field, 3, { message: 'Minimum length is 3 characters' });
});

Then we can apply this schema to both fields in our forms:

protected readonly accountForm = form(this.user, context => {
  // 👇 apply the same schema to both fields
  apply(context.login, requiredAndMinLengthSchema);
  apply(context.password, requiredAndMinLengthSchema);
});

A schema can also be applied to all elements of an array using applyEach():

protected readonly eventForm = form(
  signal({
    location: '',
    participants: ['ced'] as Array<string>
  }),
  context => {
    // 👇 apply the same schema to all participants
    applyEach(context.participants, requiredAndMinLengthSchema);
  }
);

By the way, to display an array of fields in the template, all you need is a plain old @for loop. No need for any special directive.

@for (participant of eventForm.participants; track participant) {
  <div class="participant">
    <label [for]="`participant-${$index}`">Username</label>
    <input [id]="`participant-${$index}`" [field]="participant" />
    @if (participant().touched() && !participant().valid()) {
      <div [id]="`participant-${$index}-errors`">
        @for (error of participant().errors(); track error.kind) {
          <div>{{ error.message }}</div>
        }
      </div>
    }
  </div>
}

applyEach() also works with objects, applying the schema to all properties. For example, if we want to make all fields required:

protected readonly loginForm = form(
  signal({
    login: '',
    password: ''
  }),
  context => {
    // 👇 apply the required schema to all fields
    applyEach(context, requiredSchema);
  }
);

Asynchronous validation with validateAsync() and validateHttp()

The validation can also be asynchronous thanks to the validateAsync() function, which uses a Resource:

// email is not already registered (async validation)
validateAsync(form.email, {
  params: (email: ChildFieldContext<string>) => email.value(),
  factory: (params: Signal<string | undefined>) =>
    resource({
      // 👇 Params contains the `email` signal and is used to trigger the resource
      params,
      // the loader makes an HTTP call to check if the email is already registered
      loader: async (loaderParams: ResourceLoaderParams<string | undefined>) =>
        // returns true if the email is already registered
        await this.userService.isRegistered(loaderParams.params)
    }),
    // 👇 This is called with the result of the resource
    onSuccess: (response: { isRegistered: boolean }) =>
      response.isRegistered
        ? {
            kind: 'email-already-registered',
            message: 'Email is already registered'
          }
        : undefined,
    // 👇 This is called if the resource fails
    onError: () =>
      ({
        kind: 'email-check-failed',
        message: 'Could not verify if the email is already registered'
      })
});

The resource is automatically called when the value of the field changes, and the field is marked as pending() while waiting for the result of the resource. When the resource resolves, if the onSuccess function returns an error, the error is added to the errors() of the field.

As an alternative, if you need to call an HTTP endpoint to validate a field, you can use a httpResource() with the validateHttp() function:

// email is not already registered (async validation)
validateHttp(form.email, {
  // 👇 httpResource is triggered when the email signal changes
  request: (email: ChildFieldContext<string>) => `/api/users/check?email=${email.value()}`,
  onSuccess: (response: { isRegistered: boolean }) =>
    response.isRegistered
      ? {
          kind: 'email-already-taken',
          message: 'Email is already taken'
        }
      : undefined,
  onError: () =>
    ({
      kind: 'email-check-failed',
      message: 'Could not verify if the email is already taken'
    })
});

In that case, you only need to provide the request URL.

Server errors

The submit function we saw earlier has another interesting feature: if the action you provide returns errors, they are automatically added to the errors() of the corresponding fields (or to the form if you don't provide a field).

await submit(this.loginForm, async form => {
  const { login, password } = form().value();
  try {
    await this.userService.authenticate(login, password);
    return;
  } catch (error) {
    // 👇 add an error to the form
    const message = (error as Error).message;
    return [{ kind: 'invalid-credentials', message }];
  }
});

Dynamic behavior

A more realistic form often has some dynamic behavior, for example, enabling/disabling or hiding/displaying fields based on the value of other fields, or adding/removing validators.

To hide a field, you can use the hidden() function, which lets you specify when the field should be hidden or not, based on the state of the form. When a field is hidden, it is excluded from validation. In the template, you can then use an @if block to display the field only if it's not hidden.

Similarly, to disable a field, you can use the disabled() function in the form schema. Here, the password field is disabled unless the login is valid:

protected readonly loginForm = form(this.credentials, f => {
  required(f.login);
  // 👇 disable the password field until a valid login is provided
  disabled(f.password, ({ stateOf }) => {
    return !stateOf(f.login).valid();
  });
});

You can go even further and return a string from the disabled logic, in order to populate the disabledReasons() of the field.

The last function to know about is readonly(), which makes a field read-only as you imagine. This is useful when you want to display an input that the user may not edit, but that should still be submitted with the form. disabled() and readonly() automatically update the HTML attributes of the corresponding field in the template: you don't need to do anything special for an input to be disabled in the template part of the form.

NOTE: Disabled fields do not behave the same way as in reactive forms: their value is still part of the form value when submitting it. The positive side is that you don't have to deal with the fact that all properties are potentially undefined. The negative side is that you need to be careful before sending this value to the server: it could be invalid if it comes from a disabled field. The same goes for hidden fields.

These functions can be used together to create complex dynamic behavior, and they can be applied conditionally. The second parameter of the form() function is not a reactive function, which means you can't use if/else conditions based on signals. You need to use applyWhenValue()/applyWhen() to apply these functions conditionally.

For example, to make a field mandatory only when a user is not admin (and let's say this info is stored in a signal called isAdmin):

protected readonly accountForm = form(this.user, form => {
  // 👇 the birth year field is required only when the user is not admin
  applyWhenValue(
    form,
    () => !this.userService.isAdmin(),
    form => {
      required(form.birthYear, { message: 'Birth year is required for non-admin users' });
    }
  );
});

If the logic depends on another field, you can use applyWhen() instead, which gives you access to the form and its fields via valueOf/stateOf:

protected readonly accountForm = form(this.user, form => {
  // 👇 the birth year field is required only when the isAdmin checkbox is not checked
  applyWhen(
    form,
    context => !context.valueOf(form.isAdmin),
    form => {
      required(form.birthYear, { message: 'Birth year is required for non-admin users' });
    }
  );
});

Debouncing form updates with debounce()

Sometimes you want to control when form control changes are synchronized to the form model. For example, you might want to delay validation while a user is still typing, to avoid triggering expensive validation checks on every keystroke.

Signal forms provide a debounce() function that allows you to manage the timing of form field updates:

protected readonly accountForm = form(this.user, form => {
  // 👇 let's say the computation is expensive, and we want to debounce it
  debounce(form.password, 500);
  // password is strong enough
  validate(form.password, (context: ChildFieldContext<string>) =>
    isTooWeak(context.value()) ? { kind: 'too-weak', message: 'Password is too weak' } : undefined
  );
});

When a debounce rule is applied to a field, the value signal may lag behind the controlValue signal. The controlValue represents the current value in the form control (what the user just typed), while value represents the debounced value that gets synchronized to the form model.

The debounce delay can be a fixed number (in milliseconds) or a function that returns a promise.

Custom form components with FormValueControl and FormCheckboxControl

Sometimes we need to build custom form components. We used to have ControlValueAccessor for that in previous form systems, but with signal forms, there is a new and simpler way to do that: by implementing the FormUiControl interface, or, more accurately, one of its children interfaces FormCheckboxControl (for boolean-like controls) and FormValueControl (for other types of values).

Note: Custom form components that use ControlValueAccessor are still supported in signal forms, but it's recommended to use these new interfaces for new components.

These interfaces only define the inputs and models your component should have, and Angular will bind the Field directive state into these signals automatically.

The properties to implement are:

  • value: (only for FormValueControl) a model input defining the value of the field
  • checked: (only for FormCheckboxControl) a model input defining whether the field is checked or not
  • touched: model input defining whether the field has been touched or not
  • disabled/disabledReasons/readonly/hidden/dirty: inputs containing the state of the field
  • errors/invalid/pending: inputs containing the validation state of the field
  • required/minLength/maxLength/min/max/pattern: inputs containing the built-in validators applied to the field

All these properties are optionals, except value or checked depending on the interface you implement. Most properties are read-only inputs, except value, checked, and touched, which are model inputs, as the component is expected to update them.

Let's build a Rating component, that allows the user to select a rating from 1 to 5. The value will be a number, so let's implement the FormValueControl<number> interface:

@Component({
  selector: 'app-rating',
  template: `
    @let v = value();
    @for (pickableValue of pickableValues; track pickableValue) {
      <button
        [class.selected]="v != null && pickableValue <= v"
        type="button"
        (click)="value.set(pickableValue)"
        [disabled]="disabled()"
        (blur)="touched.set(true)"
      >
        {{ pickableValue }}
      </button>
    }
  `,
  styles: ['.selected { background-color: yellow }']
})
export class Rating implements FormValueControl<number | null> {
  readonly value = model<number | null>(null);
  readonly touched = model(false);
  readonly disabled = input(false);

  protected readonly pickableValues = [0, 1, 2, 3, 4, 5];
}

The template is straightforward. We iterate over the possible ratings and display a button for each rating. Clicking a button updates the value. And when it loses focus (blur event), we mark the field as touched.

Changing the value of the model inputs automatically updates the state of the field in the form. And in the other direction, changing the field state automatically update the inputs of the component.

Our component can then be used in a form like this:

<app-rating id="rating" [field]="movieForm.rating" />

Sub forms

Sometimes you don't want to define the entire form in a single component. You may want to split it into several sub-forms, each defined in its own component. With signal forms, this is easy to do by passing a field to the child component.

Let's say our user form has an address sub-form:

protected readonly user = signal({
  firstname: '',
  lastname: '',
  address: {
    number: '',
    street: '',
    zipcode: '',
    city: ''
  }
});
protected readonly userForm = form(this.user, f => {
  required(f.firstname, { message: 'First name is required' });
  required(f.lastname, { message: 'Last name is required' });
  // 👇the address schema can be defined separately and reused
  apply(f.address, addressSchema);
});

We can define an Address component that accepts a FieldTree for the address:

@Component({
  selector: 'app-address-form',
  template: `
    @let address = addressField();
    <div>
      @let number = address.number;
      <label for="number">Number</label>
      <input id="number" [field]="number" />
      @if (number().touched() && number().invalid()) {
        <div>
          @for (error of number().errors(); track error.kind) {
            <div>{{ error.message }}</div>
          }
        </div>
      }
    </div>
    <input id="street" [field]="address.street" />
    <input id="zipcode" [field]="address.zipcode" />
    <input id="city" [field]="address.city" />
  `,
  imports: [Field]
})
export class AddressForm {
  readonly addressField = input.required<FieldTree<AddressModel>>();
}

Then we can use our Address component in the user registration template:

<app-address-form [addressField]="userForm.address" />

That makes it really easy to split a form into several components!

Conclusion

Signal forms bring a new way to build forms in Angular, based on signals and with a fresh approach to validation and reactivity. While still experimental for now, they already offer a lot of interesting features that make building forms easier and more enjoyable than before.

Among the benefits, the type safety is better than before; cross-field validation is easier to implement (no need to use FormGroup anymore); and building custom form components is simpler as well (FormValueControl is much simpler than ControlValueAccessor).

We lose the ng-valid/ng-dirty/... CSS classes that were automatically added to form controls, but we can easily recreate them using the properties of the FieldTree, even if it requires a bit more work in the template.

A few things would be great to have in the future, like better support for displaying error messages in the template (iterating over errors() is a bit low-level for most use cases).

What's next?

We've now covered the comprehensive features of signal forms: from creating forms and handling submissions to custom validators, asynchronous validation, server error handling, dynamic field behavior, debouncing form updates, custom form components, and sub forms.

Key takeaways from this series:

  • Signal forms provide a new signal-based approach to forms in Angular
  • They offer better type safety and easier cross-field validation than previous form systems
  • Custom validators are simple functions using validate() that return validation error objects
  • Asynchronous validation uses validateAsync() or validateHttp()
  • Server errors can be automatically mapped to form fields
  • Dynamic behavior is achieved via applyWhen() and applyWhenValue()
  • Form updates can be debounced using debounce() to control the timing of synchronization
  • Custom form components use FormValueControl, which is simpler than ControlValueAccessor
  • Sub forms can be easily created by passing fields to child components

Signal forms are still experimental but show great promise for making form development more intuitive and type-safe in Angular applications!

All our materials (ebook, online training and training) are up-to-date with these changes if you want to learn more!


Photo de Cédric Exbrayat
Cédric Exbrayat

← Article plus ancien