Signal Forms
Desde la versión v21, Angular ha introducido una nueva forma de gestionar formularios utilizando ampliamente la nueva api de señales. Esto permite una gestión reactiva tanto de los valores como de los estados del formulario basa en señales.
Al contrario que los formularios de plantilla o reactivos, no necesitamos importar ningún módulo en nuestro componentes. Solamente impotaremos las directivas que necesitemos.
Creando y vinculando el modelo
Para crear un objeto que represente un Signal Form, primero creamos una señal que contiene el modelo de datos que va a gestionar el formulario. En nuestro caso será el producto que enviaremos al servidor. Después creamos el formulario y le pasamos la señal.
Para vincular los campos de formulario a los campos del producto, utilizamos la directiva (previa importación) [formField]. Cabe destacar que los campos con esta directiva no deben tener el atributo name.
Desde Angular 21.1 se ha renombrado la directiva field a formField. Si estás en la versión 21.0.x deberías usar field en su lugar.
<!-- ... -->
<input
type="text"
class="form-control"
[formField]="productForm.description"
/>
<!-- ... -->En nuestro caso, para guardar la imagen en base64, lo hacemos a partir del campo del formulario. De esta manera, cada vez que cambia un campo, se actualiza la signal del modelo. Si hicieramos el cambio en el modelo directamente, tendríamos que generar un nuevo objeto de tipo producto si queremos que la signal detecte un cambio de valor.
<!-- ... -->
<input
type="file"
class="form-control"
accept="image/*"
encodeBase64
(encoded)="productForm.imageUrl().setControlValue($event)"
/>
<!-- ... -->
Modificar valores desde código
Si queremos modificar un valor desde código podemos hacerlo desde el objeto del formulario como acabamos de ver con la imagen anteriormente, o directamente actualizar el modelo con update o set (requiere generar un nuevo objeto).
setFormData(p: Product) {
this.productModel.set(p);
}
emptyFields() {
this.productModel.set({
description: '',
available: '',
imageUrl: '',
rating: 1,
price: 0,
});
}
clearDescription() {
this.productForm.description().value.set(''); // Si hay debounce tarda
this.productForm.description().setControlValue(''); // Cambio inmediato
}Envío del formulario
Esta API de formularios no gestiona el elemento <form>, por lo que simplemente vincularemos el evento submit del formulario. Además, es obligatorio pasarle el evento para llamar a preventDefault() y evitar que se recargue la página.
<!-- ... -->
<form (submit)="addProduct($event)">
<!-- ... -->
</form>
<!-- ... -->
Resetear formulario
Para resetear el formulario, podemos utilizar el método reset() del mismo. Por defecto sólo quita los estados dirty y touched, pero le podemos pasar por parámetro un objeto con el valor de los campos que queremos asignar.
addProduct(event: Event) {
event.preventDefault();
this.#productsService.insertProduct(this.productModel()).subscribe((product) => {
this.added.emit(product);
this.productForm().reset({
description: '',
available: '',
imageUrl: '',
rating: 1,
price: 0,
});
}
}Validar formulario
La validación de formularios con Signal Forms es relativamente parecida a la validación de los formularios reactivos, ya que ambas se definen desde la clase del componente.
Para validar, incluso aplicarle otros estados a los campos como deshabilitar o debounce, le pasamos una función como segundo parámetro al formulario. En esta función recibimos un objeto Schema que permite aplicarle funciones de validación a los campos.
Para validar un campo, tenemos funciones de validación predefinidas que se llaman igual que los atributos equivalentes en HTML (required, min, pattern, email, etc.). Estas funciones además del campo y en algunos casos un valor, también tienen opciones configurables como el mensaje de error que se mostrará.
export class ProductForm implements CanComponentDeactivate {
//...
productForm = form(this.productModel, (schema) => {
required(schema.description, { message: 'Description cannot be empty' });
required(schema.available, { message: 'Available date cannot be empty' });
required(schema.imageUrl);
required(schema.price, { message: 'Price cannot be empty' });
minLength(schema.description, 5, {
message: (context) =>
`You must enter at least ${5 - context.value().length} characters more`,
});
min(schema.price, 0.01, { message: 'Price cannot be 0 or negative' });
});
//...
}Si hay algún campo que no queremos que esté en el modelo de datos que usamos en el formulario y queremos mostrar estilos y errores de validación para el mismo, habría que crear otro formulario auxiliar con dicho campo:
<!-- ... -->
<input
type="file"
class="form-control"
accept="image/*"
encodeBase64
(encoded)="productForm.imageUrl().setControlValue($event)"
[formField]="imageField"
/>
@for (error of imageField().errors(); track error.kind) {
<div class="invalid-feedback">{{ error.message }}</div>
}
@if (productForm.imageUrl().value()) {
<div class="row mb-3">
<div class="col-sm-10 offset-sm-2">
<img class="product-img" [src]="productModel().imageUrl" alt="" />
</div>
</div>
}
<!-- ... -->Podemos representar un campo simplemente almacenando una signal con un valor en lugar de un objeto como acabamos de ver.
Gestionar las clases de validación
Por defecto, los formularios de tipo signal no añaden las clases de Angular (ng-valid, ng-invalid, ng-pristine, etc...) a los formularios. Si necesitamos estas clases en nuestro proyecto podríamos configurarlo de manera muy sencilla y global desde app.config.ts.
//...
import { provideSignalFormsConfig, SignalFormsConfig } from '@angular/forms/signals';
export const NG_STATUS_CLASSES: SignalFormsConfig['classes'] = {
'ng-touched': ({state}) => state().touched(),
'ng-untouched': ({state}) => !state().touched(),
'ng-dirty': ({state}) => state().dirty(),
'ng-pristine': ({state}) => !state().dirty(),
'ng-valid': ({state}) => state().valid(),
'ng-invalid': ({state}) => state().invalid(),
'ng-pending': ({state}) => state().pending(),
};
export const appConfig: ApplicationConfig = {
providers: [
//...
provideSignalFormsConfig({
classes: NG_STATUS_CLASSES,
}),
]
};En nuestro caso, ya que estamos trabajando con las clases de validación de Bootstrap, solo necesitaríamos las clases 'is-valid' y 'is-invalid'. Y en nuestro caso las pondremos automáticamente cuando el campo haya sido visitado (touched) y dependiendo del estado (valid).
//...
import { provideSignalFormsConfig } from '@angular/forms/signals';
export const appConfig: ApplicationConfig = {
providers: [
//...
provideSignalFormsConfig({
classes: {
'is-valid': ({state}) => state().touched() && state().valid(),
'is-invalid': ({state}) => state().touched() && state().invalid()
}
}),
],
};
Ya no necesitaríamos hacer nada más, dejando el HTML más limpio que en el caso de los formularios de plantilla o reactivos. Todos los inputs con la directiva formField tendrían las clases que hemos puesto automáticamente.
Mostrar mensajes de validación
Se pueden mostrar mensajes de validación comprobando errores específicos. Para ello accedemos al array de errores del campo usando formulario.campo().errors(). El formato de los errores es el siguiente:
[ { "kind": "required", "message": "Description cannot be empty" } ]Lo más sencillo sería recorrer los errores y mostrar los mensajes para cada campo del formulario:
<!-- ... -->
<div class="col-sm-10">
<input
type="text"
class="form-control"
[formField]="productForm.description"
/>
@for (error of productForm.description().errors(); track error.kind) {
<div class="invalid-feedback">{{ error.message }}</div>
}
</div>
<!-- ... -->
Crear validadores personalizados
Los validadores personalizados son funciones igual que ocurre en los formularios reactivos. Sin embargo, el parámetro y el tipo de dato devuelto es diferente. Cuando no hay error devolvemos null y en caso contrario un objecto de error con 2 campos:
- kind → Nombre del error
- message → Mensaje de error personalizado
Los validadores personalizados se crean con la función validate:
export class ProductForm implements CanComponentDeactivate {
//...
productForm = form(this.productModel, (schema) => {
//...
validate(schema.available, ({value}) => {
const today = new Date().toISOString().slice(0, 10);
if(value() && value() < today) {
return {
kind: 'minDate',
message: 'Date can\'t be before today'
}
}
return null;
})
});
//...
}Si queremos envolver el validador en una función separada, para reutilizarlo en más componentes, y que reciba los parámetros de entrada correspondientes, simplemente tenemos que llamar a validate dentro de la misma. No tenemos que devolver nada en la función principal.
import { SchemaPath, validate } from '@angular/forms/signals';
export function minDate(
field: SchemaPath<string>,
minDate: string,
options?: { message?: string },
) {
validate(field, ({ value }) => {
if (value() && value() < minDate) {
return {
kind: 'minDate',
message: options?.message ?? `Date can't be before ${minDate}`,
};
}
return null;
});
}
Validadores de grupo
Para crear un grupo en el formulario, simplemente tenemos que reflejarlo en el modelo, a partir de ahí podemos aplicarle un validador a una propiedad que englobe a otras, y dentro del validador acceder a los campos internos sin problema.
Así validaríamos que al menos un checkbox de un array de días esté marcado:
export class RestaurantFormComponent {
restaurantModel = signal({
//...
daysOpen: new Array(7).fill(false)
});
restaurantForm = form(this.restaurantModel, (schema) => {
//...
validate(schema.daysOpen, ({value}) => {
if(value().every(v => v === false)) {
return {
kind: 'anyChecked',
message: 'You must select at least 1 day'
}
}
return null;
})
});
}Así validaríamos que 2 campos sean iguales agrupándolos
export class UserFormComponent {
userModel = signal({
name: '',
emailGroup: {
email: '',
repeatEmail: ''
}
});
userForm = form(this.userModel, (schema) => {
required(schema.emailGroup.email, {message: 'Email is required'});
required(schema.emailGroup.repeatEmail, {message: 'Repeat email is required'});
email(schema.emailGroup.email, {message: 'Email must have the right format'});
email(schema.emailGroup.repeatEmail, {message: 'Email must have the right format'});
validate(schema.emailGroup, ({value}) => {
if(value().email !== value().repeatEmail) {
return {
kind: 'sameEmail',
message: 'Emails are not equal'
}
}
return null;
})
});
}Integrar otros campos en el validador
Sin necesidad de agruparlos, podemos añadir el valor de un segundo o tercer campo en la validación. Para ello, la función de validación, además de value, puede utilizar valueOf, que permite acceder al valor de cualquier campo del formulario.
Veamos como validar si 2 campos tienen el mismo valor sin necesidad de agruparlos:
export class UserFormComponent {
userModel = signal({
name: '',
email: '',
repeatEmail: ''
});
userForm = form(this.userModel, (schema) => {
required(schema.email, {message: 'Email is required'});
required(schema.repeatEmail, {message: 'Repeat email is required'});
email(schema.email, {message: 'Email must have the right format'});
email(schema.repeatEmail, {message: 'Email must have the right format'});
validate(schema.repeatEmail, ({value, valueOf}) => {
const email = valueOf(schema.email);// Creamos dependencia con este valor
if(value() !== email) {
return {
kind: 'sameEmail',
message: 'Emails are not equal'
}
}
return null;
})
});
}
Mejorar el canDeactivate del formulario
La forma de consultar si el formulario ha sido modificado o no para que el guard de tipo CanDeactivate nos permita salir de la página sin preguntar es similar a la que vimos con los fomrularios reactivos. Podemos consultar el estado modificado (dirty) directamente desde el objeto del formulario.
export class ProductForm implements CanComponentDeactivate {
//...
canDeactivate() {
return (
this.saved ||
!this.productForm().dirty() ||
confirm('¿Quieres abandonar la página?. Los cambios se perderán...')
);
}
//...
}Busqueda con retardo (debounce)
Los signal forms tienen la función debounce integrada. Si se la aplicamos a un campo (con un retardo en ms), no se actualizará el valor del modelo (signal asociada) hasta que el usuario haya estado esa cantidad de milisegundos sin cambiar el valor del campo. Con este tipo de formularios, la implementación es muy sencilla.
<!-- ... -->
<input type="text" [formField]="searchField" placeholder="Buscar">
<!-- ... -->
<!-- Ya no necesitamos filteredProducts(), al filtrarse desde la API -->
@for (product of products(); track product.id) {
<product-item [product]="product" [showImage]="showImage()" class="row g-0"
(deleted)="deleteProduct(product)" />
}
<!-- ... -->Custom Controls
Los Signal Forms permiten la creación de controles de formularios personalizados. Estos pueden ser desde elementos estándar HTML gestionados de una manera un poco diferente, a diseños totalmente personalizados. El requisito mínimo consiste en crear una signal de tipo model llamado generalmente value, que gestione el valor vinculado.
También se pueden vincular estados como invalid (input), dirty (input), touched (model), disabled (input), etc. como se puede ver en la documentación oficial.
Vamos a hacer un ejemplo de un custom control que gestiona una lista desplegable <select>, pero al contrario de lo que ocurre por defecto (valor de tipo string), maneja el valor numérico (number), lo cual puede ser interesante si nuestro modelo del formulario así lo necesita.
Creando un custom control (select numérico)
Lo primero es crear un componente que implemente la interfaz FormValueControl (para valores booleanos tenemos FormCheckBoxControl). Como va a ser un componente con lógica y plantilla muy básica, lo vamos a gestionar todo dentro de un único archivo:
ng g c form-controls/numeric-select --inline-template --inline-style --flat
Además del valor y estados básicos que vamos a gestionar, también le vamos a pasar las clases CSS, la id y el array de opciones a mostrar.
import { Component, input, model } from '@angular/core';
import { FormValueControl } from '@angular/forms/signals';
@Component({
selector: 'numeric-select',
imports: [],
template: `
<select
#select
[id]="id()"
[value]="value()"
(change)="value.set(+select.value)"
[class]="classes()"
[class.ng-invalid]="invalid()"
[class.ng-valid]="!invalid()"
[class.ng-touched]="touched()"
(blur)="touched.set(true)"
>
<option value="0">Select an option...</option>
@for (option of options(); track option.value) {
<option [value]="option.value">{{ option.text }}</option>
}
</select>
`,
styles: ``,
})
export class NumericSelect implements FormValueControl<number> {
id = input<string>('');
options = input<{ value: number; text: string }[]>();
classes = input<string>('');
value = model<number>(0);
touched = model<boolean>(false);
invalid = input<boolean>(false);
}Así lo integraríamos en un formulario de ejemplo. Cabe destacar que el atributo id debemos pasarlo entre corchetes ([id]) aunque el valor sea de tipo string para que el navegador no lo asigne al elemento numeric-select y que el campo asociado debe ser en este caso de tipo number (nuestro objetivo).
<div>
<label for="province" class="block text-gray-700 font-bold mb-2">Province</label>
<numeric-select
[id]="'province'"
[formField]="provinceIdField"
classes="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white"
[options]="provincesOptions()"
></numeric-select>
</div>