Módulo 18: Signal Forms
Objetivos del módulo
- Entender qué aporta Signal Forms frente a los formularios por plantilla y los reactivos
- Crear un formulario con
forma partir de una señal y conectarlo con la directivaFormField - Leer y escribir el estado de cada campo: valor,
touched,dirty,valid,pending… - Saber qué tipos de dato admite cada control nativo y qué ocurre con los errores de análisis
- Validar con
required,email,min,max,minLength,maxLength,pattern,minDateymaxDate - Escribir validadores propios con
validateyvalidateTree, también entre campos - Aplicar lógica condicional con
disabled,hidden,readonly,applyWhenyapplyWhenValue - Validar en el servidor con
validateAsyncyvalidateHttp, y usar esquemas de Zod convalidateStandardSchema - Enviar con
submityFormRoot, y colocar en cada campo los errores que devuelve la API - Cargar un registro para editarlo y encadenar campos dependientes
- Gestionar listas con
applyEach - Crear controles propios con
FormValueControlyFormCheckboxControl - Convivir con formularios reactivos usando
compatFormySignalFormControl - Saber con exactitud qué cambia si trabajas en la LTS
🔄 Cambia en Angular 22.0. Signal Forms pasa de experimental a estable. En la LTS (Angular 21.2) existe bajo el mismo paquete y con la misma idea, pero es experimental y algunas funciones cambian de forma o no existen. Si usas la LTS: casi todo el módulo compila igual; cada apartado distinto lleva su marca, y la sección 21 reúne todas las diferencias comprobadas con Angular 21.2.25.
1. Por qué un tercer tipo de formulario
En los módulos 16 y 17 viste los dos sistemas clásicos. Los dos resuelven bien el problema, pero nacieron antes que las señales:
- En los formularios por plantilla, el estado vive en directivas escondidas en el HTML. Son cómodos para formularios pequeños, pero es difícil probarlos y razonar sobre ellos.
- En los formularios reactivos, el estado vive en un árbol de objetos
FormGroupyFormControlque tú construyes. Son potentes, pero el modelo de datos se duplica: tienes tu interfazEmpleadoy además un árbol paralelo con la misma forma. Para enterarte de un cambio hay que suscribirse avalueChanges, un Observable que no encaja de forma natural concomputed.
Signal Forms parte de otra idea: el modelo es una señal normal y corriente, y el formulario es una vista reactiva sobre ella.
const empleado = signal({ nombre: '', salario: 0 }); // tus datos, sin envoltorios
const formulario = form(empleado); // el estado del formulario
Con eso ya tienes tres ventajas:
- Una sola fuente de verdad. No hay un árbol de controles con su propia copia del valor. Si cambias
empleado, cambia el formulario. Si el usuario escribe, cambiaempleado. - Todo es una señal. El valor, los errores y
validse leen en uncomputedo en la plantilla como cualquier otra señal, sin suscripciones y sin fugas. Esto encaja con la detección de cambios zoneless del módulo 09. - Las reglas se declaran aparte. Validación, campos deshabilitados y campos ocultos se escriben en un esquema (schema), una función independiente que puedes reutilizar y probar sin pintar nada.
Figura 1. Las piezas de Signal Forms. El modelo es la única copia de los datos; el resto se calcula a partir de él.
| Plantilla | Reactivos | Signal Forms | |
|---|---|---|---|
| Dónde está el valor | Directiva ngModel | FormControl | Tu señal |
| Tipado | Débil | Bueno, con FormGroup<...> | Se infiere del modelo |
| Cómo se observa un cambio | (ngModelChange) | valueChanges | Leyendo una señal |
| Dónde están las reglas | Atributos HTML | Lista de validadores en el código | Función de esquema |
| Paquete | @angular/forms | @angular/forms | @angular/forms/signals |
💡 Consejo: Signal Forms no deja obsoletos a los otros dos sistemas. Hay mucho código con formularios reactivos y seguirá funcionando. En código nuevo con Angular 22, Signal Forms es la opción natural; en la sección 19 verás cómo migrar poco a poco.
2. Tu primer formulario
Vamos a construir un formulario de acceso para la aplicación EMPRESA. Necesitas cuatro cosas: un modelo, form, unas reglas y la directiva FormField.
// src/app/login/login.ts
import { JsonPipe } from '@angular/common';
import { Component, signal } from '@angular/core';
import { email, form, FormField, required } from '@angular/forms/signals';
interface Credenciales {
email: string;
password: string;
}
@Component({
selector: 'app-login',
imports: [FormField, JsonPipe],
template: `
<h2>Acceso a EMPRESA</h2>
<label>
Email
<input type="email" [formField]="formulario.email" />
</label>
@if (formulario.email().touched() && formulario.email().invalid()) {
@for (error of formulario.email().errors(); track $index) {
<p class="error">{{ error.message }}</p>
}
}
<label>
Contraseña
<input type="password" [formField]="formulario.password" />
</label>
@if (formulario.password().touched() && formulario.password().invalid()) {
<p class="error">La contraseña es obligatoria</p>
}
<p>¿Válido?: {{ formulario().valid() }}</p>
<pre>{{ credenciales() | json }}</pre>
`,
styles: `
label { display: block; margin-top: 0.75rem; font-weight: 600; }
input { display: block; width: 18rem; padding: 0.4rem; font: inherit; }
.error { margin: 0.25rem 0; color: #c62828; }
`,
})
export class Login {
// 1. El modelo: una señal con un objeto normal
protected readonly credenciales = signal<Credenciales>({ email: '', password: '' });
// 2. El formulario: form(modelo, esquema)
protected readonly formulario = form(this.credenciales, (ruta) => {
// 3. Las reglas
required(ruta.email, { message: 'El email es obligatorio' });
email(ruta.email, { message: 'Ese email no parece válido' });
required(ruta.password);
});
}
Vamos paso a paso:
- Import. Todo viene de
@angular/forms/signals. Si importas de@angular/formsestarás en los formularios clásicos. form(modelo, esquema)recibe una señal escribible y devuelve un árbol de campos (FieldTree), que aquí guardas enformulario. Tiene la misma forma que el modelo:formulario.email,formulario.password.rutano contiene datos. Es un objeto que describe el modelo para señalar a qué campo se aplica cada regla.required(ruta.email)se lee «el campo email es obligatorio», y se ejecuta una sola vez, al crear el formulario.[formField]="formulario.email"es la directivaFormField. Conecta el<input>con el campo en los dos sentidos. Hay que añadirla aimports.formulario.email()lleva paréntesis: al llamar al campo obtienes su estado (FieldState), con señales comotouched(),invalid()oerrors(). Fíjate en la diferencia:formulario.emailes el campo yformulario.email()es su estado.

Figura 2. El formulario de acceso con un email incorrecto. El modelo se actualiza con cada tecla, también mientras el dato no es válido.
⚠️ Importante: El modelo contiene siempre lo que hay en el control, sea válido o no. La validación no impide que el valor llegue a la señal; solo informa. Quien impide enviar datos incorrectos es
submit, que verás en la sección 14.
💡 Consejo:
formtiene que ejecutarse en un contexto de inyección, porque crea efectos y recursos internos. Si lo declaras como propiedad del componente, como aquí, ya lo está. En otro sitio, pásale un inyector:form(modelo, esquema, { injector }).
Este ejemplo aún no tiene botón de enviar. Un <form> nativo recargaría la página, y la forma correcta de evitarlo la verás en la sección 14.
3. El árbol de campos
Cada nodo del árbol, no solo las hojas, tiene su propio estado. Si el modelo es un objeto anidado, el árbol también lo es:
const modelo = signal({
nombre: 'Ana',
direccion: { calle: 'Mayor 1', ciudad: 'Madrid' },
});
const f = form(modelo);
f.nombre().value(); // 'Ana'
f.direccion.ciudad().value(); // 'Madrid'
f.direccion().value(); // { calle: 'Mayor 1', ciudad: 'Madrid' }
f().value(); // el objeto entero
3.1. El estado de un campo
| Señal o método | Qué dice | Notas |
|---|---|---|
value | Valor del campo | Es escribible: campo().value.set('x') |
controlValue | Lo que hay ahora en el control | Se adelanta a value si usas debounce |
touched | El usuario ha salido del control | También lo activa un intento de envío |
dirty | El usuario lo ha modificado | No se activa si cambias el valor por código |
valid | No tiene errores ni validaciones pendientes | |
invalid | Tiene algún error | No es lo contrario de valid |
pending | Hay un validador asíncrono en marcha | |
errors | Errores de este campo | Lista de objetos con kind y message |
errorSummary | Errores de este campo y de sus hijos | Útil en la raíz |
disabled, hidden, readonly | Estado de disponibilidad | Se configuran en el esquema |
disabledReasons | Por qué está deshabilitado | Lista con message |
required, min, max, minLength, maxLength, pattern | Límites declarados en el esquema | Para mostrarlos en la interfaz |
submitting | Se está enviando | En la raíz |
name | Nombre único del campo | Lo usa FormField para el atributo name |
markAsTouched(), markAsDirty() | Marcan el estado a mano | |
reset(valor?) | Limpia touched y dirty | Mira el apartado siguiente |
focusBoundControl() | Lleva el foco al control |
Los estados se propagan hacia arriba: la raíz está invalid si cualquier hijo lo está, y touched si cualquier hijo lo está.
⚠️ Importante: Un campo con una validación asíncrona en curso no es
validniinvalid: las dos señales valenfalse, ypendingvaletrue. Por eso!valid()einvalid()no significan lo mismo. Para desactivar un botón de envío,invalid()deja pasar los estados pendientes y!valid()no.
Figura 3. La validez tiene tres estados, no dos. Mientras hay un validador asíncrono en marcha, el campo no es válido ni inválido.
3.2. Modelo y formulario están sincronizados
Como solo hay una copia de los datos, puedes escribir por cualquiera de los dos lados:
modelo.update((m) => ({ ...m, nombre: 'Luis' })); // la plantilla lo muestra
f.nombre().value.set('Eva'); // modelo().nombre pasa a ser 'Eva'
⚠️ Importante: El modelo es inmutable, como cualquier señal:
modelo().nombre = 'Eva'no avisa a nadie. El módulo 05 explica el motivo.
3.3. reset no restaura el valor
Este detalle sorprende a quien viene de los formularios reactivos. Comprobado en 22.2 y 21.2:
f.nombre().value.set('otro');
f.nombre().markAsTouched();
f().reset(); // touched y dirty a false, pero el valor sigue siendo 'otro'
f().reset({ nombre: 'Ana' }); // además escribe ese valor en el modelo
reset() limpia las marcas de interacción, no los datos. Si quieres volver a los valores iniciales, guárdalos en una constante y pásalos:
const INICIAL = { nombre: '', salario: null };
const modelo = signal(INICIAL);
// ...
formulario().reset(INICIAL);
3.4. isFieldTree 🆕 v22.1
🆕 Desde Angular 22.1. No existe en la LTS (Angular 21). Si usas la LTS: evita la comprobación dinámica accediendo a los campos por su nombre, o conviértelos con
as FieldTree<unknown>sabiendo que TypeScript no lo verifica.
isFieldTree(valor) indica si algo es un campo. Solo hace falta cuando recorres los campos de forma dinámica, como harás en la sección 14.
4. Controles nativos y tipos de dato
FormField funciona con <input>, <textarea> y <select>. Mira cómo queda cada tipo:
// src/app/campos/campos-nativos.ts
import { JsonPipe } from '@angular/common';
import { Component, signal } from '@angular/core';
import { form, FormField } from '@angular/forms/signals';
@Component({
selector: 'app-campos-nativos',
imports: [FormField, JsonPipe],
template: `
<label>Texto <input [formField]="f.nombre" /></label>
<label>Área de texto <textarea [formField]="f.notas"></textarea></label>
<label>Número <input type="number" [formField]="f.salario" /></label>
<label>Fecha como texto <input type="date" [formField]="f.fechaAlta" /></label>
<label>Fecha como Date <input type="date" [formField]="f.fechaBaja" /></label>
<label>Casilla <input type="checkbox" [formField]="f.activo" /></label>
<label>
Lista
<select [formField]="f.deptId">
<option value="">Elige...</option>
<option value="10">DIRECCIÓN</option>
<option value="20">VENTAS</option>
</select>
</label>
<fieldset>
<legend>Radio</legend>
<label><input type="radio" value="M" [formField]="f.turno" /> Mañana</label>
<label><input type="radio" value="T" [formField]="f.turno" /> Tarde</label>
</fieldset>
<pre>{{ datos() | json }}</pre>
`,
})
export class CamposNativos {
protected readonly datos = signal({
nombre: 'Ana',
notas: '',
salario: 2500 as number | null,
fechaAlta: '2026-03-01',
fechaBaja: null as Date | null,
activo: true,
deptId: '10',
turno: 'M',
});
protected readonly f = form(this.datos);
}
| Control | Tipo del modelo | Notas |
|---|---|---|
<input> de texto, <textarea> | string | Un string \| null no compila |
type="number" | number \| null | Vaciar el control escribe null |
type="date" | string o Date \| null | El texto va en formato AAAA-MM-DD |
type="checkbox" | boolean | |
<select> | string | Los value son texto: aunque el modelo empiece con un número, al elegir recibe "10" |
type="radio" | string | Mismo campo en todos los radios del grupo |
⚠️ Importante: Declara los campos numéricos como
number | null, nunca comonumber. Al vaciar untype="number", Angular escribenullaunque el tipo del modelo diganumber, y TypeScript no se entera porque ocurre en ejecución. Connumber | nullel compilador te obliga a tratar ese caso. Inicializa también connull, no conundefined: un campo opcional del modelo (salario?: number) no genera campo en el árbol.
💡 Consejo: Si el modelo tiene
string | null(habitual al recibir datos de una API),<input [formField]>daTS2322. Convierte el dato a''al cargar el formulario y de vuelta anullal enviar. Así el formulario tiene su propio modelo, adaptado a los controles; lo harás en la sección 15.
4.1. Lo que FormField te impide escribir
Las reglas del esquema mandan sobre el control, y el compilador rechaza lo que podría contradecirlas:
<input [formField]="f.nombre" [disabled]="true" /> <!-- NG8022 -->
<input [formField]="f.nombre" [value]="'hola'" /> <!-- NG8022 -->
<input [formField]="f.nombre" required /> <!-- NG8022 -->
NG8022: Binding to '[disabled]' is not allowed on nodes using the '[formField]' directive
NG8022: Setting the 'required' attribute is not allowed on nodes using the '[formField]' directive
Ocurre igual en las dos versiones. La regla práctica: si afecta al estado del campo, va en el esquema. A cambio, FormField sí copia required, min, max, minLength, maxLength y disabled del estado del campo al elemento HTML.
4.2. Errores de análisis
Si en un control numérico aparece algo que no es un número, el valor no llega al modelo. El campo recibe un error de tipo parse (clase NativeInputParseError), que se comporta como los demás. Esto no se puede probar en cualquier navegador, porque muchos impiden teclear letras en un type="number"; lo verás mejor con el apartado siguiente.
4.3. Números en <input type="text"> 🆕 v22.0
🆕 Desde Angular 22.0. No existe en la LTS (Angular 21):
<input [formField]>con unnumber | nulldaTS2322. Si usas la LTS: usatype="number", o un control propio contransformedValuecomo el de la sección 17. No fuerces el tipo con$any: en 21 el modelo recibiría el texto"9"en lugar del número.
<input type="text" inputmode="decimal" [formField]="f.salario" />
Sirve cuando no quieres las flechas ni el comportamiento de type="number". Con el modelo { salario: 7 }, comprobado en ejecución:
| El usuario escribe | Modelo | errors() |
|---|---|---|
9 | 9 (número) | ninguno |
abc | sigue en 9 | parse |
5. Esquemas y validadores
5.1. Los validadores integrados
| Función | Valor | Ejemplo | kind del error |
|---|---|---|---|
required(ruta) | Cualquiera | required(p.nombre) | required |
email(ruta) | string | email(p.email) | email |
min(ruta, n) | number \| null | min(p.salario, 1) | min |
max(ruta, n) | number \| null | max(p.salario, 9000) | max |
minLength(ruta, n) | Con length o size | minLength(p.nombre, 2) | minLength |
maxLength(ruta, n) | Con length o size | maxLength(p.notas, 200) | maxLength |
pattern(ruta, regex) | string | pattern(p.telefono, /^\d{9}$/) | pattern |
minDate(ruta, fecha) 🆕 | Date \| null | Sección 7 | minDate |
maxDate(ruta, fecha) 🆕 | Date \| null | Sección 7 | maxDate |
Todos aceptan un último argumento de configuración:
required(p.nombre, { message: 'El nombre es obligatorio' });
// message también puede calcularse
maxLength(p.nombre, 50, {
message: ({ value }) => `Te sobran ${value().length - 50} caracteres`,
});
// en vez de message puedes dar un error completo
required(p.deptId, { error: requiredError({ message: 'Elige un departamento' }) });
Los límites también pueden ser reactivos, si pasas una función en lugar de un valor. La función recibe el contexto del campo, que verás en la sección 9:
max(p.comision, ({ valueOf }) => valueOf(p.salario) ?? undefined);
📘 Concepto: Salvo
required, los validadores ignoran un campo vacío:patternno se queja de un'', niminde unnull. Es lo que quieres en un campo opcional, que se valida solo si se rellena. Si además es obligatorio, añaderequired.
5.2. Esquemas reutilizables
Las reglas pueden vivir fuera del componente. schema crea un objeto reutilizable, y apply lo aplica a una parte del modelo:
// src/app/alta-empleado/esquema-contacto.ts
import { email, maxLength, pattern, required, schema } from '@angular/forms/signals';
export interface Contacto {
email: string;
telefono: string;
}
export const esquemaContacto = schema<Contacto>((p) => {
required(p.email, { message: 'El email es obligatorio' });
email(p.email, { message: 'Ese email no parece válido' });
maxLength(p.email, 80, { message: 'Máximo 80 caracteres' });
pattern(p.telefono, /^[0-9]{9}$/, { message: 'Debe tener nueve dígitos' });
});
const esquemaCliente = schema<{ nombre: string; contacto: Contacto }>((p) => {
required(p.nombre);
apply(p.contacto, esquemaContacto); // mismas reglas, en otra parte del árbol
});
Si el objeto Contacto no está anidado, puedes aplicar el esquema a la raíz, porque un Empleado con email y telefono tiene todo lo que Contacto necesita: apply(p, esquemaContacto).
Con el esquema fuera del componente, puedes probar las reglas sin pintar nada, como verás en la sección 20.
5.3. min y max solo con números 🔄 v22.0
🔄 Cambia en Angular 22.0.
minymaxdejan de admitir campos de texto: unstringdaTS2345. En la LTS (Angular 21) sí compilan connumber | string | null. Si usas la LTS: declara igualmente los campos comonumber | null, y tu código pasará a 22 sin tocarlo.
5.4. La opción when en validadores 🆕 v22.0
🆕 Desde Angular 22.0.
min,max,minLength,maxLengthypatternno la admiten en la LTS (Angular 21): daTS2353y, si fuerzas el tipo, la opción se ignora sin avisar y el validador se aplica siempre.requiredsí la tiene en las dos versiones. Si usas la LTS: envuelve el validador enapplyWhen, que funciona igual en 21 y 22.
Una regla puede aplicarse solo si se cumple una condición:
required(p.nif, { when: ({ valueOf }) => valueOf(p.tipo) === 'empresa' });
pattern(p.nif, /^[A-Z][0-9]{8}$/, { when: ({ valueOf }) => valueOf(p.tipo) === 'empresa' });
Con la LTS, y también en 22 si son varias reglas con la misma condición:
applyWhen(p, ({ value }) => value().tipo === 'empresa', (e) => {
required(e.nif);
pattern(e.nif, /^[A-Z][0-9]{8}$/);
});
6. El esquema del empleado
A partir de aquí usarás un ejemplo completo: el alta de un empleado contra POST /api/empleados. Este es el modelo del formulario y un primer esquema, al que irás añadiendo piezas. Los datos que pide la API están en el README de empresa-api:
// src/app/modelos.ts
export const API = 'http://localhost:3000/api';
export interface Empleado {
empId: number;
nombre: string;
apellidos: string;
email: string;
telefono: string | null;
fechaAlta: string;
oficio: string;
salario: number;
comision: number | null;
jefeId: number | null;
deptId: number;
}
export interface Departamento {
deptId: number;
nombre: string;
localidad: string;
}
export interface Producto {
productoId: number;
nombre: string;
precio: number;
stock: number;
}
export interface Cliente {
clienteId: number;
nombre: string;
nif: string;
}
export interface Pagina<T> {
contenido: T[];
pagina: number;
tamano: number;
total: number;
paginas: number;
}
Fíjate en que el modelo del formulario no es Empleado. La API tiene deptId numérico y telefono que puede ser null, mientras que los controles trabajan con texto. Separar los dos modelos te evita pelear con los tipos:
// src/app/alta-empleado/esquema.ts
import {
apply, debounce, hidden, maxLength, min, required, schema, validate, validateHttp,
} from '@angular/forms/signals';
import { API, Empleado, Pagina } from '../modelos';
import { esquemaContacto } from './esquema-contacto';
export interface EmpleadoForm {
nombre: string;
apellidos: string;
email: string;
telefono: string;
fechaAlta: string;
oficio: string;
salario: number | null;
comision: number | null;
deptId: string;
jefeId: string;
}
export const EMPLEADO_VACIO: EmpleadoForm = {
nombre: '', apellidos: '', email: '', telefono: '', fechaAlta: '', oficio: '',
salario: null, comision: null, deptId: '', jefeId: '',
};
// Convierte el modelo del formulario en lo que pide la API
export function aApi(d: EmpleadoForm) {
return {
...d,
telefono: d.telefono || null,
fechaAlta: d.fechaAlta || undefined,
comision: d.oficio === 'VENDEDOR' ? d.comision : null,
deptId: Number(d.deptId),
jefeId: d.jefeId ? Number(d.jefeId) : null,
};
}
export const esquemaEmpleado = schema<EmpleadoForm>((p) => {
required(p.nombre, { message: 'El nombre es obligatorio' });
maxLength(p.nombre, 50, { message: 'Máximo 50 caracteres' });
required(p.apellidos, { message: 'Los apellidos son obligatorios' });
apply(p, esquemaContacto);
required(p.oficio, { message: 'Elige un oficio' });
required(p.salario, { message: 'El salario es obligatorio' });
min(p.salario, 1, { message: 'El salario debe ser mayor que cero' });
required(p.deptId, { message: 'Elige un departamento' });
// Sección 10: la comisión solo existe para los vendedores
hidden(p.comision, { when: ({ valueOf }) => valueOf(p.oficio) !== 'VENDEDOR' });
required(p.comision, { message: 'La comisión es obligatoria para un vendedor' });
validate(p.comision, ({ value, valueOf }) => {
const salario = valueOf(p.salario) ?? 0;
return (value() ?? 0) > salario
? { kind: 'comisionAlta', message: 'La comisión no puede superar el salario' }
: null;
});
// Sección 11: comprobar en el servidor que el email está libre
debounce(p.email, 300);
validateHttp(p.email, {
request: ({ value }) => `${API}/empleados?q=${encodeURIComponent(value())}&tamano=5`,
onSuccess: (respuesta: Pagina<Empleado>, { value }) =>
respuesta.contenido.some((e) => e.email === value())
? { kind: 'emailOcupado', message: 'Ese email ya lo tiene otro empleado' }
: null,
onError: () => ({ kind: 'sinRespuesta', message: 'No se pudo comprobar el email' }),
});
});
Aún no conoces algunas de estas piezas: las verás en las próximas secciones, y el componente que usa este esquema está en la sección 14.
7. Fechas con minDate y maxDate 🆕 v22.0
🆕 Desde Angular 22.0.
minDate,maxDate,minDateErrorymaxDateErrorno existen en la LTS (Angular 21). Si usas la LTS: escribe unvalidateque compare las fechas congetTime(); el código completo está en esta misma sección. Se pierde el atributomin/maxautomático en el<input>, y la señalmin()del campo.
Trabajan con campos Date | null. Veamos un contrato cuya fecha de fin no puede ser anterior a la de inicio:
// src/app/contrato/contrato.ts
import { Component, signal } from '@angular/core';
import { form, FormField, maxDate, minDate, required } from '@angular/forms/signals';
import { ErroresCampo } from '../comun/errores-campo';
const LIMITE = new Date('2030-12-31');
@Component({
selector: 'app-contrato',
imports: [FormField, ErroresCampo],
template: `
<label>Inicio <input type="date" [formField]="f.inicio" /></label>
<app-errores-campo [campo]="f.inicio" />
<label>Fin <input type="date" [formField]="f.fin" /></label>
<app-errores-campo [campo]="f.fin" />
`,
})
export class Contrato {
protected readonly datos = signal({
inicio: null as Date | null,
fin: null as Date | null,
});
protected readonly f = form(this.datos, (p) => {
required(p.inicio, { message: 'Indica el inicio' });
maxDate(p.inicio, LIMITE, { message: 'Como mucho, hasta 2030' });
minDate(p.fin, ({ valueOf }) => valueOf(p.inicio) ?? undefined, {
message: 'El fin no puede ser anterior al inicio',
});
});
}
Si la función del límite devuelve undefined, el validador no se aplica: mientras inicio esté vacío, fin no se comprueba. Además, FormField escribe el atributo min en el <input type="date">, y el calendario del navegador deja de ofrecer los días anteriores. El límite se relee cada vez que cambia inicio, porque se calcula con una señal.
Con la LTS, sustituye minDate y maxDate por validate:
// src/app/lts/contrato.ts
import { Component, signal } from '@angular/core';
import { form, FormField, required, validate } from '@angular/forms/signals';
import { ErroresCampo } from '../comun/errores-campo';
const LIMITE = new Date('2030-12-31');
@Component({
selector: 'app-contrato-lts',
imports: [FormField, ErroresCampo],
template: `
<label>Inicio <input type="date" [formField]="f.inicio" /></label>
<app-errores-campo [campo]="f.inicio" />
<label>Fin <input type="date" [formField]="f.fin" /></label>
<app-errores-campo [campo]="f.fin" />
`,
})
export class ContratoLts {
protected readonly datos = signal({
inicio: null as Date | null,
fin: null as Date | null,
});
protected readonly f = form(this.datos, (p) => {
required(p.inicio, { message: 'Indica el inicio' });
validate(p.inicio, ({ value }) => {
const inicio = value();
return inicio && inicio.getTime() > LIMITE.getTime()
? { kind: 'maxDate', message: 'Como mucho, hasta 2030' }
: null;
});
validate(p.fin, ({ value, valueOf }) => {
const fin = value();
const inicio = valueOf(p.inicio);
return fin && inicio && fin.getTime() < inicio.getTime()
? { kind: 'minDate', message: 'El fin no puede ser anterior al inicio' }
: null;
});
});
}
8. Mostrar los errores
8.1. Un componente para no repetirte
La condición «tocado e inválido» se repite en cada campo, así que conviene un componente pequeño:
// src/app/comun/errores-campo.ts
import { Component, input } from '@angular/core';
import { Field } from '@angular/forms/signals';
@Component({
selector: 'app-errores-campo',
template: `
@if (campo()().touched() && campo()().invalid()) {
<ul class="errores" role="alert">
@for (error of campo()().errors(); track $index) {
<li>{{ error.message ?? error.kind }}</li>
}
</ul>
}
`,
styles: `
.errores { margin: 0.25rem 0 0; padding: 0; list-style: none; color: #c62828; font-size: 0.9rem; }
`,
})
export class ErroresCampo {
readonly campo = input.required<Field<any>>();
}
Field<T> es el tipo de «algo que, al llamarlo, devuelve un FieldState», y cualquier campo del árbol lo cumple. Por eso aparece campo()(): el primer par de paréntesis lee el input, y el segundo lee el estado del campo.
8.2. Qué contiene un error
Cada error es un objeto con estas propiedades:
| Propiedad | Contenido |
|---|---|
kind | Tipo de error: 'required', 'min', o el que tú inventes |
message | Texto opcional |
fieldTree | Campo en el que está |
Los errores integrados añaden datos propios. Por ejemplo, el de min lleva min, y el de maxLength lleva maxLength. Para aprovecharlos, compara kind y usa una función que traduzca a texto. Así los mensajes están en un único sitio, sin rellenar message en cada regla:
// src/app/comun/mensajes.ts
import { ValidationError } from '@angular/forms/signals';
export function mensajeDe(error: ValidationError): string {
if (error.message) return error.message;
switch (error.kind) {
case 'required': return 'Este campo es obligatorio';
case 'email': return 'Ese email no parece válido';
case 'min': return `Debe ser ${(error as unknown as { min: number }).min} o más`;
case 'parse': return 'El valor no tiene el formato esperado';
default: return 'Valor no válido';
}
}
8.3. getError 🆕 v22.0
🆕 Desde Angular 22.0.
FieldState.getErrorno existe en la LTS (Angular 21). Si usas la LTS: usacampo().errors().find((e) => e.kind === 'min'), que da el mismo error, aunque sin tipar sus datos propios.
getError busca un error por su kind y lo devuelve con el tipo adecuado:
@if (f.salario().getError('min'); as error) {
<p>El mínimo es {{ error.min }}</p>
}
8.4. Clases CSS 🔄 v22.0
🔄 Cambia en Angular 22.0. La función de cada clase recibe un
FormFieldBindingen lugar delFormFieldde la LTS (Angular 21). Si usas la LTS: el ejemplo compila igual, porque los dos tipos tienenstate,elementyfocus.
Signal Forms no añade por defecto las clases ng-invalid o ng-touched que conoces de los formularios clásicos. Las declaras tú al arrancar la aplicación, con los nombres que quieras:
// src/app/comun/config-formularios.ts
import { provideSignalFormsConfig } from '@angular/forms/signals';
export const proveedorFormularios = provideSignalFormsConfig({
classes: {
'campo-error': (campo) => campo.state().touched() && campo.state().invalid(),
'campo-ok': (campo) => campo.state().touched() && campo.state().valid(),
},
});
Añade proveedorFormularios a providers en app.config.ts. Si estás migrando y tus estilos ya usan ng-invalid, la sección 19 te muestra NG_STATUS_CLASSES, que las reproduce.
8.5. markAsTouched marca a los hijos 🔄 v22.0
🔄 Cambia en Angular 22.0.
markAsTouched()marca también a los hijos, y admite{ skipDescendants: true }para marcar solo ese nodo. En la LTS (Angular 21) marca únicamente el nodo en el que lo llamas, y no recibe argumentos. Si usas la LTS:f().markAsTouched()no muestra los errores de los campos; llámalo en cada campo, o usasubmit, que sí los marca todos en las dos versiones.
Este cambio no da ningún error al compilar. Con el mismo código, comprobado en las dos versiones:
f().markAsTouched();
f.nombre().touched(); // true en 22, false en 21
9. Validadores propios
9.1. validate
validate(ruta, funcion) recibe una función que devuelve null si todo va bien, o un error (o una lista de errores) con kind y message. Se recalcula sola cuando cambia cualquier señal que lea.
validate(p.nombre, ({ value }) =>
value().trim().length === 0 ? { kind: 'soloEspacios', message: 'No vale solo con espacios' } : null,
);
9.2. El contexto
La función recibe un objeto de contexto, que puedes desestructurar:
| Propiedad | Qué da |
|---|---|
value() | Valor de este campo |
state | Estado de este campo |
fieldTree | Este campo, como árbol |
valueOf(ruta) | Valor de otro campo |
stateOf(ruta) | Estado de otro campo |
fieldTreeOf(ruta) | Otro campo, como árbol |
pathKeys() | Ruta del campo desde la raíz |
key() | Clave dentro del padre (no en la raíz) |
index() | Posición en un array (solo en elementos de applyEach) |
Con valueOf, una regla puede depender de otro campo. Si ese campo cambia, la regla vuelve a evaluarse, sin ningún código adicional. Es la gran diferencia con los formularios reactivos, donde había que colocar el validador en el FormGroup.
9.3. El estado es de solo lectura dentro del contexto 🔄 v22.0
🔄 Cambia en Angular 22.0.
state,stateOfyfieldTreeOfson de solo lectura:state.value.set(...)daTS2339, igual questateOf(p.x).markAsTouched(). En la LTS (Angular 21) compila. Si usas la LTS: no escribas nunca desde una regla; una regla debe calcular, no modificar. Así el código sirve en las dos versiones.
Escribir desde un validador crearía un ciclo: la regla cambia un valor, el valor recalcula la regla. El tipo ReadonlyFieldState lo impide en 22.
9.4. validateTree: errores en otros campos
validate pone el error en el campo al que se aplica. validateTree puede dirigirlo a otro campo, indicando fieldTree. Se usa cuando la regla pertenece al grupo y quieres que el error aparezca en un campo concreto:
// src/app/clave/cambio-clave.ts
import { Component, signal } from '@angular/core';
import { form, FormField, minLength, required, validateTree } from '@angular/forms/signals';
import { ErroresCampo } from '../comun/errores-campo';
@Component({
selector: 'app-cambio-clave',
imports: [FormField, ErroresCampo],
template: `
<label>Contraseña nueva <input type="password" [formField]="f.clave" /></label>
<app-errores-campo [campo]="f.clave" />
<label>Repítela <input type="password" [formField]="f.repetida" /></label>
<app-errores-campo [campo]="f.repetida" />
`,
})
export class CambioClave {
protected readonly datos = signal({ clave: '', repetida: '' });
protected readonly f = form(this.datos, (p) => {
required(p.clave, { message: 'Escribe una contraseña' });
minLength(p.clave, 8, { message: 'Al menos 8 caracteres' });
validateTree(p, ({ value, fieldTreeOf }) =>
value().clave !== value().repetida
? { kind: 'noCoincide', message: 'Las contraseñas no coinciden', fieldTree: fieldTreeOf(p.repetida) }
: null,
);
});
}
💡 Consejo: Con
validate(p.repetida, ...)yvalueOf(p.clave)obtendrías lo mismo. ReservavalidateTreepara los casos en que una sola comprobación produce errores en varios campos, como los duplicados de una lista.
10. Lógica condicional
Validar es solo una parte del esquema. También decide qué campos están disponibles.
| Función | Efecto en el control | ¿Se valida? | ¿Lo pinta Angular? |
|---|---|---|---|
disabled | Atributo disabled | No | Sí |
readonly | Atributo readonly | No | Sí |
hidden | Ninguno | No | Sí: lo ocultas tú con @if |
Comprobado en las dos versiones: un campo deshabilitado, oculto o de solo lectura no genera errores aunque tenga required, y no impide enviar. El valor, en cambio, sigue en el modelo.
10.1. disabled, hidden y readonly con { when } 🔄 v22.0
🔄 Cambia en Angular 22.0. Pasar la condición directamente, como
hidden(p.x, fn), queda obsoleto; se escribehidden(p.x, { when: fn }). Las funciones no están obsoletas: solo esa forma de llamarlas. Si usas la LTS: usahidden(p.x, fn), porque{ when }daTS2353en 21 y, si fuerzas el tipo, falla al ejecutar confn is not a function. La forma antigua sigue compilando en 22 con un aviso de obsoleto.
La regla es solo de forma, y se migra línea a línea:
| Angular 21.2 | Angular 22 |
|---|---|
hidden(p.x, fn) | hidden(p.x, { when: fn }) |
readonly(p.x, fn) | readonly(p.x, { when: fn }) |
disabled(p.x, fn) | disabled(p.x, { when: fn }) |
disabled(p.x, 'motivo') | disabled(p.x, { when: 'motivo' }) |
disabled(p.x) | disabled(p.x), igual en las dos |
readonly(p.x) | readonly(p.x), igual en las dos |
// src/app/condicional/ficha-acceso.ts
import { Component, signal } from '@angular/core';
import { disabled, form, FormField, hidden, readonly, required } from '@angular/forms/signals';
@Component({
selector: 'app-ficha-acceso',
imports: [FormField],
template: `
<label>Rol
<select [formField]="f.rol">
<option value="USER">USER</option>
<option value="ADMIN">ADMIN</option>
</select>
</label>
<label>Código de empleado <input [formField]="f.codigo" /></label>
<label>Departamento <input [formField]="f.departamento" /></label>
@for (motivo of f.departamento().disabledReasons(); track $index) {
<p class="motivo">{{ motivo.message }}</p>
}
<label><input type="checkbox" [formField]="f.externo" /> Es personal externo</label>
@if (!f.empresa().hidden()) {
<label>Empresa <input [formField]="f.empresa" /></label>
}
`,
styles: `
label { display: block; margin-top: 0.75rem; font-weight: 600; }
input:not([type='checkbox']), select { display: block; width: 18rem; padding: 0.4rem; font: inherit; }
.motivo { margin: 0.25rem 0; color: #5d4037; font-size: 0.9rem; }
`,
})
export class FichaAcceso {
protected readonly datos = signal({
rol: 'USER', codigo: 'E-100', departamento: 'VENTAS', externo: false, empresa: '',
});
protected readonly f = form(this.datos, (p) => {
readonly(p.codigo);
disabled(p.departamento, { when: ({ valueOf }) => (valueOf(p.rol) !== 'ADMIN' ? 'Solo un ADMIN puede cambiarlo' : false) });
hidden(p.empresa, { when: ({ valueOf }) => !valueOf(p.externo) });
required(p.empresa);
});
}
En el caso de disabled, la función puede devolver un boolean o un texto. Un texto significa «deshabilitado por este motivo», y aparece en disabledReasons(). Es una forma cómoda de explicar al usuario por qué no puede tocar algo.

Figura 4. readonly, disabled con motivo y hidden en el mismo formulario.
⚠️ Importante:
hiddenno quita nada de la pantalla. Solo pone atruela señalhidden()y excluye el campo de la validación. Tienes que escribir el@if. Si olvidas el@if, el campo se ve aunque no se valide; es un fallo silencioso que Angular solo advierte en modo de desarrollo.
10.2. hidden permanente 🆕 v22.2
🆕 Desde Angular 22.2.
hidden(ruta)sin condición no compila en la LTS (Angular 21): daTS2554, porque allí la función es obligatoria. Si usas la LTS: escribehidden(p.x, () => true), que hace lo mismo.
hidden(p.empId) deja un campo siempre oculto. Es útil para valores que viajan en el modelo pero que el usuario no edita, como el identificador de un registro.
10.3. applyWhen y applyWhenValue
Cuando varias reglas comparten condición, agrúpalas. Las dos funciones existen igual en 21 y 22:
applyWhen(p, ({ value }) => value().tipo === 'empresa', (e) => {
required(e.nif);
required(e.razonSocial);
});
applyWhenValue recibe una función que mira solo el valor. Si es un predicado de tipo, TypeScript estrecha el modelo dentro del esquema, lo que resulta muy útil con uniones:
applyWhenValue(p, (v): v is Empresa => v.tipo === 'empresa', (e) => {
required(e.nif); // e es de tipo Empresa
});
11. Validación asíncrona
Comprobar en el servidor que un email está libre exige una petición. Eso significa estados de carga, peticiones canceladas y respuestas desordenadas, justo lo que resuelven los recursos del módulo 13. Por eso los validadores asíncronos se apoyan en ellos.
11.1. validateHttp
Es la forma directa cuando basta con una petición GET o similar:
validateHttp(p.email, {
request: ({ value }) => `${API}/empleados?q=${encodeURIComponent(value())}&tamano=5`,
onSuccess: (respuesta: Pagina<Empleado>, { value }) =>
respuesta.contenido.some((e) => e.email === value())
? { kind: 'emailOcupado', message: 'Ese email ya lo tiene otro empleado' }
: null,
onError: () => ({ kind: 'sinRespuesta', message: 'No se pudo comprobar el email' }),
});
| Opción | Para qué sirve |
|---|---|
request | Calcula la URL o un HttpResourceRequest. Si devuelve undefined, no se pide nada |
onSuccess | Convierte la respuesta en errores, o en null |
onError | Qué hacer si la petición falla |
options | Opciones de httpResource, como parse |
⚠️ Importante: Los validadores asíncronos solo se ejecutan si los síncronos del campo han pasado. Con
ana@no sale ninguna petición: primero tiene que ser un email válido. Comprobado en las dos versiones. Por eso no hace falta comprobar el formato dentro derequest.
Mientras dura la petición, el campo está pending, y puedes mostrarlo:
@if (f.email().pending()) {
<p>Comprobando...</p>
}

Figura 5. La API dice que el email ya existe. El error aparece en el campo, como cualquier otro.
11.2. No preguntar en cada tecla: debounce
Sin más, cada tecla lanza una petición (las anteriores se cancelan, pero ya han salido). La regla debounce retrasa la llegada del valor al modelo:
debounce(p.email, 300); // espera 300 ms sin teclear
debounce(p.email, 'blur'); // espera a salir del campo
Comprobado en las dos versiones: mientras esperas, controlValue() ya tiene lo tecleado, pero value() y el modelo conservan lo anterior. Al salir del campo se escribe de inmediato, y por eso el usuario no se queda sin validar si se va rápido. También puedes pasar una función propia con el tipo Debouncer.
11.3. Opciones debounce y when 🆕 v22.0
🆕 Desde Angular 22.0. Estas opciones no existen en
validateHttpni envalidateAsyncen la LTS (Angular 21): danTS2353. Si usas la LTS: para el retraso usa la regladebounce(p.email, 400)del apartado anterior, que retrasa todo el campo; en vez dewhen, devuelveundefinedenrequest, oparams, si no quieres petición.
validateHttp(p.email, {
request: ({ value }) => `${API}/empleados?q=${encodeURIComponent(value())}&tamano=5`,
debounce: 400, // retrasa solo la petición, no el modelo
when: ({ valueOf }) => valueOf(p.comprobar),
onSuccess: () => null,
onError: () => null,
});
La diferencia con la regla debounce es sutil: la regla retrasa la escritura en el modelo, mientras que la opción deja que el modelo se actualice al momento y retrasa solo la petición. Durante esa espera el campo ya está pending.
11.4. validateAsync 🔄 v22.0
🔄 Cambia en Angular 22.0.
factorypuede devolver cualquierResource, mientras que en la LTS (Angular 21) debe ser unResourceRef. Si usas la LTS:resource(...)yhttpResource(...)devuelvenResourceRef, así que el ejemplo compila igual.
validateHttp es un caso particular de validateAsync, que acepta cualquier recurso. Se usa para llamadas que no son una simple petición HTTP, como un servicio propio que devuelve una promesa:
// src/app/asincrono/producto-libre.ts
import { Component, inject, resource, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { form, FormField, required, validateAsync } from '@angular/forms/signals';
import { firstValueFrom } from 'rxjs';
import { ErroresCampo } from '../comun/errores-campo';
import { API, Pagina, Producto } from '../modelos';
@Component({
selector: 'app-producto-libre',
imports: [FormField, ErroresCampo],
template: `
<label>Nombre del producto <input [formField]="f.nombre" /></label>
@if (f.nombre().pending()) { <p>Comprobando...</p> }
<app-errores-campo [campo]="f.nombre" />
`,
})
export class ProductoLibre {
private readonly http = inject(HttpClient);
protected readonly datos = signal({ nombre: '' });
protected readonly f = form(this.datos, (p) => {
required(p.nombre);
validateAsync(p.nombre, {
params: ({ value }) => value(),
factory: (nombre) =>
resource({
params: nombre,
loader: ({ params }) =>
firstValueFrom(
this.http.get<Pagina<Producto>>(`${API}/productos`, { params: { q: params } }),
),
}),
onSuccess: (pagina, { value }) =>
pagina?.contenido.some((x) => x.nombre === value())
? { kind: 'repetido', message: 'Ya hay un producto con ese nombre' }
: null,
onError: () => ({ kind: 'sinRespuesta', message: 'No se pudo comprobar' }),
});
});
}
params calcula de qué depende la validación, y factory crea el recurso una sola vez, al montar el campo: recibe una señal con esos parámetros, y el recurso se recarga solo cuando cambian.
Puedes usar varios validadores asíncronos en el mismo campo. Comprobado en las dos versiones: se ejecutan todos y sus errores se acumulan.
11.5. reloadValidation 🆕 v22.0
🆕 Desde Angular 22.0.
FieldState.reloadValidationno existe en la LTS (Angular 21). Si usas la LTS: crea una señal contador, léela enrequesto enparams, e increméntala para forzar otra petición.
// Angular 22
f.email().reloadValidation();
// Angular 21.2: equivalente con una señal
const intento = signal(0);
// en params: ({ value }) => ({ email: value(), intento: intento() })
intento.update((n) => n + 1);
Vuelve a lanzar los validadores asíncronos del campo sin que cambie el valor. Se usa, por ejemplo, tras un error de red, o después de liberar en otra pantalla el dato que estaba ocupado.
12. Esquemas de Zod con validateStandardSchema
Standard Schema es una interfaz común que implementan bibliotecas de validación como Zod o Valibot. Con ella puedes compartir las reglas entre el cliente y un servidor Node, por ejemplo. Primero instala la biblioteca:
npm install zod
// src/app/registro/registro.ts
import { Component, signal } from '@angular/core';
import { form, FormField, validateStandardSchema } from '@angular/forms/signals';
import { z } from 'zod';
import { ErroresCampo } from '../comun/errores-campo';
const reglas = z.object({
usuario: z.string().min(3, 'Al menos 3 letras'),
edad: z.number('Indica tu edad').min(18, 'Debes ser mayor de edad').nullable(),
});
@Component({
selector: 'app-registro',
imports: [FormField, ErroresCampo],
template: `
<label>Usuario <input [formField]="f.usuario" /></label>
<app-errores-campo [campo]="f.usuario" />
<label>Edad <input type="number" [formField]="f.edad" /></label>
<app-errores-campo [campo]="f.edad" />
`,
})
export class Registro {
protected readonly datos = signal({ usuario: '', edad: null as number | null });
protected readonly f = form(this.datos, (p) => validateStandardSchema(p, reglas));
}
Cada problema que detecta Zod se coloca en su campo, como un error de kind standardSchema, y el mensaje se conserva. Esta función también puede combinarse con otras reglas del esquema, y su segundo argumento puede ser una función, para elegir el esquema de forma reactiva. El resultado es igual en 21.2 y 22, con Zod 4.
13. Formularios en una plantilla real
Antes de enviar, reúne las piezas en el componente. Fíjate en que la plantilla casi no tiene lógica, porque todo está en el esquema.
// src/app/comun/sesion.ts
import { HttpClient } from '@angular/common/http';
import { inject, Injectable, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { API } from '../modelos';
@Injectable({ providedIn: 'root' })
export class Sesion {
private readonly http = inject(HttpClient);
readonly token = signal<string | null>(null);
async entrar(email: string, password: string) {
const r = await firstValueFrom(
this.http.post<{ token: string }>(`${API}/auth/login`, { email, password }),
);
this.token.set(r.token);
}
cabeceras() {
return { Authorization: `Bearer ${this.token()}` };
}
}
Sesion es un servicio mínimo para este módulo. En una aplicación real, un interceptor añadiría la cabecera, como viste en el módulo 12. Ya tienes todo lo necesario para enviar.
14. Enviar el formulario
14.1. submit
submit(formulario, accion) hace, en este orden:
- Marca todos los campos como
touched, para que se vean los errores. - Si hay errores, para y devuelve
false. La acción no se ejecuta. - Pone
submitting()atruey ejecuta tu acción asíncrona. - Coloca en sus campos los errores que devuelva la acción.
- Pone
submitting()afalsey devuelvetruesi no hubo errores.
Figura 6. Qué hace submit. La acción solo se ejecuta si el formulario es válido.
Comprobado en las dos versiones: un segundo submit mientras el primero sigue en marcha no ejecuta la acción y devuelve false, de modo que un doble clic no crea dos registros. En la LTS esa protección llegó con la versión 21.2.12.
La opción ignoreValidators controla qué bloquea el envío:
| Valor | Efecto |
|---|---|
'pending' (por defecto) | Los validadores pendientes no bloquean; los errores sí |
'none' | Tampoco se envía si hay validadores pendientes |
'all' | Se envía siempre |
14.2. Errores del servidor
Tu acción devuelve los errores que encuentre. Cada error lleva fieldTree para indicar su campo; si no lo lleva, se queda en el campo sobre el que hiciste submit, normalmente la raíz. Se borran solos cuando el usuario modifica ese valor.
La API EMPRESA responde a un error de validación con un 422 y un objeto detalles, con un mensaje por campo. Podemos traducirlo con una función genérica:
// src/app/alta-empleado/errores-api.ts
import { HttpErrorResponse } from '@angular/common/http';
import { FieldTree, isFieldTree, TreeValidationResult, ValidationError } from '@angular/forms/signals';
export function erroresDeApi(error: unknown, campos: unknown): TreeValidationResult {
if (!(error instanceof HttpErrorResponse)) {
return { kind: 'servidor', message: 'Error inesperado' };
}
const detalles: Record<string, string> | undefined = error.error?.detalles;
if (error.status !== 422 || !detalles) {
return { kind: 'servidor', message: error.error?.mensaje ?? 'El servidor no responde' };
}
const lista: ValidationError.WithOptionalFieldTree[] = [];
for (const [nombre, message] of Object.entries(detalles)) {
const campo = (campos as Record<string, unknown>)[nombre];
lista.push(
isFieldTree(campo)
? { kind: 'servidor', message, fieldTree: campo as FieldTree<unknown> }
: { kind: 'servidor', message: `${nombre}: ${message}` },
);
}
return lista;
}
Con la LTS no tienes isFieldTree, así que comprueba que la propiedad exista:
// src/app/lts/errores-api.ts
import { HttpErrorResponse } from '@angular/common/http';
import { FieldTree, TreeValidationResult, ValidationError } from '@angular/forms/signals';
export function erroresDeApi(error: unknown, campos: unknown): TreeValidationResult {
if (!(error instanceof HttpErrorResponse)) {
return { kind: 'servidor', message: 'Error inesperado' };
}
const detalles: Record<string, string> | undefined = error.error?.detalles;
if (error.status !== 422 || !detalles) {
return { kind: 'servidor', message: error.error?.mensaje ?? 'El servidor no responde' };
}
const lista: ValidationError.WithOptionalFieldTree[] = [];
for (const [nombre, message] of Object.entries(detalles)) {
const campo = (campos as Record<string, FieldTree<unknown> | undefined>)[nombre];
lista.push(campo ? { kind: 'servidor', message, fieldTree: campo } : { kind: 'servidor', message: `${nombre}: ${message}` });
}
return lista;
}
14.3. FormRoot y submission 🔄 v22.0
🔄 Cambia en Angular 22.0. En 22,
FormRootfunciona sin la opciónsubmission: solo evita que el navegador recargue la página. En la LTS (Angular 21), enviar unFormRootsinsubmissionlanzaNG01915: Cannot submit form with no submit action. Si usas la LTS: declara siempresubmissionenform;FormRoot,submissionyonInvalidexisten desde 21.2.0, así que el ejemplo compila igual.
La directiva FormRoot se pone en la etiqueta <form>. Evita la recarga de la página, añade novalidate para que el navegador no muestre sus propios mensajes, y lanza submit con la acción declarada en form:
// src/app/alta-empleado/alta-empleado.ts
import { HttpClient, httpResource } from '@angular/common/http';
import { Component, computed, inject, signal } from '@angular/core';
import { form, FormField, FormRoot, submit } from '@angular/forms/signals';
import { firstValueFrom } from 'rxjs';
import { ErroresCampo } from '../comun/errores-campo';
import { Sesion } from '../comun/sesion';
import { API, Departamento, Empleado, Pagina } from '../modelos';
import { erroresDeApi } from './errores-api';
import { aApi, EMPLEADO_VACIO, EmpleadoForm, esquemaEmpleado } from './esquema';
@Component({
selector: 'app-alta-empleado',
imports: [FormField, FormRoot, ErroresCampo],
templateUrl: './alta-empleado.html',
styleUrl: './alta-empleado.css',
})
export class AltaEmpleado {
private readonly http = inject(HttpClient);
private readonly sesion = inject(Sesion);
protected readonly departamentos = httpResource<Pagina<Departamento>>(() => `${API}/departamentos`);
protected readonly guardado = signal<Empleado | null>(null);
protected readonly datos = signal<EmpleadoForm>({ ...EMPLEADO_VACIO });
protected readonly f = form(this.datos, esquemaEmpleado, {
submission: {
action: async (campos) => {
try {
const creado = await firstValueFrom(
this.http.post<Empleado>(`${API}/empleados`, aApi(this.datos()), { headers: this.sesion.cabeceras() }),
);
this.guardado.set(creado);
this.f().reset({ ...EMPLEADO_VACIO });
return undefined;
} catch (error) {
return erroresDeApi(error, campos);
}
},
onInvalid: (campos) => campos().errorSummary()[0]?.fieldTree().focusBoundControl(),
},
});
// Departamento elegido: de él dependen los posibles jefes (sección 15)
private readonly deptId = computed(() => this.f.deptId().value());
protected readonly jefes = httpResource<Pagina<Empleado>>(() =>
this.deptId() ? `${API}/departamentos/${this.deptId()}/empleados?tamano=100` : undefined,
);
// Este método solo muestra otra forma de lanzar el envío, sin FormRoot
protected async enviarAMano() {
return submit(this.f);
}
}
<!-- src/app/alta-empleado/alta-empleado.html -->
<form [formRoot]="f">
<h2>Nuevo empleado</h2>
<div class="fila">
<label>Nombre <input [formField]="f.nombre" /></label>
<label>Apellidos <input [formField]="f.apellidos" /></label>
</div>
<app-errores-campo [campo]="f.nombre" />
<app-errores-campo [campo]="f.apellidos" />
<label>Email <input type="email" [formField]="f.email" /></label>
@if (f.email().pending()) {
<p class="aviso">Comprobando...</p>
}
<app-errores-campo [campo]="f.email" />
<label>Teléfono <input type="tel" [formField]="f.telefono" /></label>
<app-errores-campo [campo]="f.telefono" />
<label>Oficio
<select [formField]="f.oficio">
<option value="">Elige...</option>
<option value="ANALISTA">ANALISTA</option>
<option value="PROGRAMADOR">PROGRAMADOR</option>
<option value="VENDEDOR">VENDEDOR</option>
</select>
</label>
<app-errores-campo [campo]="f.oficio" />
<div class="fila">
<label>Salario <input type="number" [formField]="f.salario" /></label>
@if (!f.comision().hidden()) {
<label>Comisión <input type="number" [formField]="f.comision" /></label>
}
</div>
<app-errores-campo [campo]="f.salario" />
<app-errores-campo [campo]="f.comision" />
<div class="fila">
<label>Departamento
<select [formField]="f.deptId" (change)="f.jefeId().value.set('')">
<option value="">Elige...</option>
@if (departamentos.hasValue()) {
@for (d of departamentos.value().contenido; track d.deptId) {
<option [value]="d.deptId">{{ d.nombre }}</option>
}
}
</select>
</label>
<label>Jefe
<select [formField]="f.jefeId">
<option value="">Sin jefe</option>
@if (jefes.hasValue()) {
@for (j of jefes.value().contenido; track j.empId) {
<option [value]="j.empId">{{ j.nombre }} {{ j.apellidos }}</option>
}
}
</select>
</label>
</div>
<app-errores-campo [campo]="f.deptId" />
<app-errores-campo [campo]="f" />
<button [disabled]="f().submitting()">
{{ f().submitting() ? 'Guardando...' : 'Guardar' }}
</button>
@if (guardado(); as e) {
<p class="aviso">Creado con número {{ e.empId }}</p>
}
</form>
/* src/app/alta-empleado/alta-empleado.css */
form { max-width: 34rem; }
label { display: block; flex: 1; margin-top: 0.6rem; font-weight: 600; }
input:not([type='checkbox']), select { display: block; box-sizing: border-box; width: 100%; padding: 0.4rem; font: inherit; }
.fila { display: flex; gap: 1rem; }
.aviso { margin: 0.25rem 0; color: #283593; }
button { margin-top: 1rem; padding: 0.5rem 1.25rem; font: inherit; }
Puntos importantes:
submissiones la tercera opción deform.actionrecibe los campos, yonInvalidse ejecuta si el formulario no es válido. Aquí lleva el foco al primer error, algo muy recomendable para la accesibilidad.errorSummary()está ordenado según su posición en la página.- Sigues pudiendo llamar a
submita mano, como enenviarAMano, con o sin acción propia. Es útil para botones fuera del<form>. - El botón no se desactiva por ser inválido. Solo durante el envío. Si lo desactivas mientras hay errores, el usuario no puede pulsarlo y no sabe qué le falta; al dejarlo activo,
submitle muestra todos los errores. this.f().reset(...)deja el formulario limpio tras guardar.<app-errores-campo [campo]="f" />muestra los errores de la raíz, como los que no se asocian a ningún campo.

Figura 7. Al pulsar Guardar con el formulario vacío, submit marca todos los campos y onInvalid lleva el foco al primero con error.

Figura 8. Elegir VENDEDOR muestra el campo Comisión. La API rechaza el dato con un 422 y el mensaje aparece en el campo correspondiente.
15. Editar un registro y campos dependientes
15.1. Cargar datos en el formulario
form necesita una señal escribible, y los datos llegan más tarde. linkedSignal, del módulo 05, encaja muy bien: es escribible y se reinicia cuando cambia su origen.
// src/app/editar-empleado/editar-empleado.ts
import { HttpClient, httpResource } from '@angular/common/http';
import { Component, inject, input, linkedSignal } from '@angular/core';
import { form, FormField, FormRoot, min, required, schema } from '@angular/forms/signals';
import { firstValueFrom } from 'rxjs';
import { erroresDeApi } from '../alta-empleado/errores-api';
import { aApi, EMPLEADO_VACIO, EmpleadoForm } from '../alta-empleado/esquema';
import { ErroresCampo } from '../comun/errores-campo';
import { Sesion } from '../comun/sesion';
import { API, Empleado } from '../modelos';
function aFormulario(e: Empleado | undefined): EmpleadoForm {
if (!e) return { ...EMPLEADO_VACIO };
return {
nombre: e.nombre,
apellidos: e.apellidos,
email: e.email,
telefono: e.telefono ?? '',
fechaAlta: e.fechaAlta,
oficio: e.oficio,
salario: e.salario,
comision: e.comision,
deptId: String(e.deptId),
jefeId: e.jefeId === null ? '' : String(e.jefeId),
};
}
// No sirve esquemaEmpleado: su propio email saldría como ocupado
const esquemaEdicion = schema<EmpleadoForm>((p) => {
required(p.nombre, { message: 'El nombre es obligatorio' });
required(p.salario, { message: 'El salario es obligatorio' });
min(p.salario, 1, { message: 'El salario debe ser mayor que cero' });
});
@Component({
selector: 'app-editar-empleado',
imports: [FormField, FormRoot, ErroresCampo],
template: `
@if (empleado.isLoading()) {
<p>Cargando...</p>
} @else if (empleado.hasValue()) {
<form [formRoot]="f">
<h2>Empleado {{ id() }}</h2>
<label>Nombre <input [formField]="f.nombre" /></label>
<app-errores-campo [campo]="f.nombre" />
<label>Salario <input type="number" [formField]="f.salario" /></label>
<app-errores-campo [campo]="f.salario" />
<button [disabled]="!f().dirty() || f().submitting()">Guardar cambios</button>
</form>
}
`,
})
export class EditarEmpleado {
private readonly http = inject(HttpClient);
private readonly sesion = inject(Sesion);
readonly id = input.required<number>();
protected readonly empleado = httpResource<Empleado>(() => `${API}/empleados/${this.id()}`);
// Se reinicia cada vez que llega otro empleado
protected readonly datos = linkedSignal<EmpleadoForm>(() =>
aFormulario(this.empleado.hasValue() ? this.empleado.value() : undefined),
);
protected readonly f = form(this.datos, esquemaEdicion, {
submission: {
action: async (campos) => {
try {
await firstValueFrom(
this.http.put(`${API}/empleados/${this.id()}`, aApi(this.datos()), { headers: this.sesion.cabeceras() }),
);
this.f().reset();
return undefined;
} catch (error) {
return erroresDeApi(error, campos);
}
},
},
});
}
Comprobado en ejecución: cargar el empleado así no marca dirty, porque esa marca solo la pone el usuario. Con ello el botón «Guardar cambios» se activa únicamente cuando hay algo que guardar.
⚠️ Importante: Aquí no se reutiliza
esquemaEmpleado, porque comprueba que el email esté libre, y el email del propio empleado ya existe en la API: saldría siempre «ocupado» y no podrías guardar. En una edición real, el esquema debe recibir el identificador del registro y excluirlo de la comprobación. Lo harás en el ejercicio 5.
15.2. Campos dependientes
Ya has visto los tres tipos, en el alta de empleado:
| Dependencia | Cómo se resuelve | Dónde |
|---|---|---|
| Una regla depende de otro campo | valueOf dentro del esquema | Comisión frente a salario |
| Un campo aparece según otro | hidden con { when } | Comisión si es VENDEDOR |
| Las opciones dependen de otro campo | computed + httpResource | Jefes del departamento |
| Un valor se limpia al cambiar otro | Evento (change) | jefeId al cambiar de departamento |
Para las opciones de un <select>, lee el valor del campo con un computed y úsalo en un httpResource:
private readonly deptId = computed(() => this.f.deptId().value());
protected readonly jefes = httpResource<Pagina<Empleado>>(() =>
this.deptId() ? `${API}/departamentos/${this.deptId()}/empleados?tamano=100` : undefined,
);
Para limpiar un campo, usa el evento (change) y no un effect. Un effect también se ejecutaría al cargar un registro para editarlo, y borraría el jefe que acabas de recibir. El evento (change) solo salta cuando actúa el usuario.
16. Listas con applyEach
Un pedido tiene un número variable de líneas. En el modelo, es simplemente un array, y el árbol de campos tiene un campo por elemento:
// src/app/nuevo-pedido/nuevo-pedido.ts
import { CurrencyPipe } from '@angular/common';
import { httpResource } from '@angular/common/http';
import { Component, computed, signal } from '@angular/core';
import {
applyEach, form, FormField, FormRoot, max, min, required, validate, validateTree,
} from '@angular/forms/signals';
import { ErroresCampo } from '../comun/errores-campo';
import { API, Pagina, Producto } from '../modelos';
interface LineaPedido {
productoId: string;
cantidad: number | null;
}
interface PedidoForm {
clienteId: string;
lineas: LineaPedido[];
}
const lineaNueva = (): LineaPedido => ({ productoId: '', cantidad: 1 });
@Component({
selector: 'app-nuevo-pedido',
imports: [FormField, FormRoot, ErroresCampo, CurrencyPipe],
template: `
<form [formRoot]="f">
<h2>Nuevo pedido</h2>
@for (linea of f.lineas; track linea; let i = $index) {
<div class="linea">
<select [formField]="linea.productoId" aria-label="Producto">
<option value="">Producto...</option>
@for (p of productos(); track p.productoId) {
<option [value]="p.productoId">{{ p.nombre }} ({{ p.stock }})</option>
}
</select>
<input type="number" aria-label="Cantidad" [formField]="linea.cantidad" />
<button type="button" (click)="quitar(i)">Quitar</button>
</div>
<app-errores-campo [campo]="linea.productoId" />
<app-errores-campo [campo]="linea.cantidad" />
}
<app-errores-campo [campo]="f.lineas" />
<button type="button" (click)="anadir()">Añadir línea</button>
<p><strong>Total: {{ total() | currency: 'EUR' }}</strong></p>
<button>Confirmar pedido</button>
</form>
`,
styles: `
.linea { display: flex; gap: 0.5rem; margin-top: 0.5rem; }
select { flex: 1; padding: 0.35rem; font: inherit; }
input { width: 5rem; padding: 0.35rem; font: inherit; }
button { padding: 0.35rem 0.75rem; font: inherit; margin-top: 0.5rem; }
.linea button { margin-top: 0; }
`,
})
export class NuevoPedido {
private readonly catalogo = httpResource<Pagina<Producto>>(
() => `${API}/productos?disponibles=true&tamano=100`,
);
protected readonly productos = computed(() =>
this.catalogo.hasValue() ? this.catalogo.value().contenido : [],
);
private producto(id: string) {
return this.productos().find((p) => String(p.productoId) === id);
}
protected readonly datos = signal<PedidoForm>({ clienteId: '1001', lineas: [lineaNueva()] });
protected readonly f = form(this.datos, (p) => {
applyEach(p.lineas, (linea) => {
required(linea.productoId, { message: 'Elige un producto' });
required(linea.cantidad, { message: 'Indica la cantidad' });
min(linea.cantidad, 1, { message: 'Al menos una unidad' });
// El límite depende de otro campo de la misma línea y de datos externos
max(linea.cantidad, ({ valueOf }) => this.producto(valueOf(linea.productoId))?.stock, {
message: ({ valueOf }) => `Solo quedan ${this.producto(valueOf(linea.productoId))?.stock}`,
});
});
validate(p.lineas, ({ value }) =>
value().length === 0 ? { kind: 'sinLineas', message: 'Añade al menos una línea' } : null,
);
// Un mismo producto no puede aparecer dos veces
validateTree(p.lineas, ({ value, fieldTree }) => {
const vistos = new Set<string>();
const errores = [];
for (const [i, linea] of value().entries()) {
if (linea.productoId && vistos.has(linea.productoId)) {
errores.push({
kind: 'repetido',
message: 'Ese producto ya está en otra línea',
fieldTree: fieldTree[i].productoId,
});
}
vistos.add(linea.productoId);
}
return errores;
});
}, {
submission: {
action: async () => {
console.log('Pedido listo para enviar', this.datos());
return undefined;
},
},
});
protected readonly total = computed(() =>
this.datos().lineas.reduce(
(suma, l) => suma + (this.producto(l.productoId)?.precio ?? 0) * (l.cantidad ?? 0),
0,
),
);
protected anadir() {
this.datos.update((d) => ({ ...d, lineas: [...d.lineas, lineaNueva()] }));
}
protected quitar(indice: number) {
this.datos.update((d) => ({ ...d, lineas: d.lineas.filter((_, i) => i !== indice) }));
}
}
Aquí pasa mucho, así que vamos por partes:
applyEach(p.lineas, (linea) => ...)aplica un esquema a cada elemento, tanto a los actuales como a los que se añadan.lineaes la ruta de «cualquier línea». Dentro,valueOf(linea.productoId)se refiere a la línea en la que estás, no a todas.@for (linea of f.lineas; track linea).f.lineasse puede recorrer, y cadalineaes un campo. El propio campo es una buena clave detrack, porque mantiene su identidad al moverse por el array, y Angular conserva el elemento HTML de cada línea (y con él, el foco). El estado no depende de esto:touchedy los errores viven en el árbol, no en el DOM. Comprobado: al quitar la línea del medio, las demás conservan su estado incluso contrack $index.- Añadir y quitar es trabajar con el modelo. No hay
FormArray.push. Actualizas el array conupdate, sin mutarlo, y el árbol se adapta. maxcon una función consulta el stock del producto elegido. Mientras no haya producto, devuelveundefinedy no se aplica. Las señales que lee hacen que el límite cambie al elegir otro producto.validate(p.lineas, ...)es una regla sobre el array entero, yvalidateTreecoloca cada error en la línea que corresponde.- El total es un
computednormal sobre el modelo. No necesita suscripciones ni conoce el formulario.

Figura 9. La validación de cada línea depende del producto elegido, y el total se recalcula con cada cambio. El pipe currency usa el formato inglés hasta que configures LOCALE_ID, como viste en el módulo 07.
applyEach también funciona con objetos cuyas claves son variables, como un Record<string, number>. En ese caso el contexto tiene key() en vez de index().
17. Controles propios
Con los formularios clásicos, un control propio exigía implementar ControlValueAccessor (lo viste en el módulo 17). Aquí basta con que tu componente tenga un model() llamado value.
17.1. FormValueControl 🔄 v22.0
🔄 Cambia en Angular 22.0.
touchedpasa a ser solo de entrada y el aviso de que el control se ha tocado se hace con unoutputllamadotouch. En la LTS (Angular 21) estetouchse ignora y hay que declarartouched = model(false)y escribir en él. Si usas la LTS: cambia las líneas detouchedytouchcomo se indica abajo; el resto del control es igual.
// src/app/comun/selector-estrellas.ts
import { Component, input, model, output } from '@angular/core';
import { FormValueControl } from '@angular/forms/signals';
@Component({
selector: 'app-selector-estrellas',
template: `
<div role="radiogroup" aria-label="Valoración" [class.invalido]="touched() && invalid()">
@for (n of [1, 2, 3, 4, 5]; track n) {
<button
type="button"
role="radio"
[attr.aria-checked]="n === value()"
[attr.aria-label]="n + ' estrellas'"
[class.activa]="n <= value()"
[disabled]="disabled() || readonly()"
(click)="value.set(n)"
(blur)="touch.emit()"
>★</button>
}
</div>
`,
styles: `
button { border: 0; background: none; color: #b0bec5; font-size: 1.75rem; cursor: pointer; }
button.activa { color: #ff7043; }
button:disabled { cursor: not-allowed; opacity: 0.5; }
.invalido { outline: 2px solid #c62828; display: inline-block; }
`,
})
export class SelectorEstrellas implements FormValueControl<number> {
// Lo único obligatorio
readonly value = model(0);
// Opcionales: FormField los rellena si existen
readonly disabled = input(false);
readonly readonly = input(false);
readonly invalid = input(false);
readonly touched = input(false);
// Avisa de que el usuario ha salido del control
readonly touch = output<void>();
}
Se usa igual que un <input>:
// src/app/valoracion/valoracion.ts
import { Component, signal } from '@angular/core';
import { form, FormField, min, required } from '@angular/forms/signals';
import { ErroresCampo } from '../comun/errores-campo';
import { ImporteEuros } from '../comun/importe-euros';
import { Interruptor } from '../comun/interruptor';
import { SelectorEstrellas } from '../comun/selector-estrellas';
@Component({
selector: 'app-valoracion',
imports: [FormField, ErroresCampo, SelectorEstrellas, Interruptor, ImporteEuros],
template: `
<h2>Valoración anual</h2>
<p>Desempeño</p>
<app-selector-estrellas [formField]="f.nota" />
<app-errores-campo [campo]="f.nota" />
<p>Incentivo</p>
<app-importe-euros [formField]="f.incentivo" />
<app-errores-campo [campo]="f.incentivo" />
<p><app-interruptor [formField]="f.promociona" /> Propuesto para promoción</p>
<p>Modelo: {{ datos().nota }} estrellas, {{ datos().incentivo }} euros, {{ datos().promociona }}</p>
`,
})
export class Valoracion {
protected readonly datos = signal({ nota: 0, incentivo: 1250.5 as number | null, promociona: false });
protected readonly f = form(this.datos, (p) => {
min(p.nota, 1, { message: 'Elige al menos una estrella' });
required(p.incentivo, { message: 'Indica el incentivo' });
});
}
FormField detecta qué entradas tiene tu componente y las conecta. Puedes declarar las que quieras de esta lista:
| Entrada | Tipo |
|---|---|
disabled, readonly, hidden, invalid, pending, touched, dirty, required | boolean |
errors | Lista de errores |
disabledReasons | Lista de motivos |
name | string |
min, max | Del tipo del valor en 22; number en la LTS |
minLength, maxLength | number |
pattern | Lista de RegExp |
También puede tener un método focus(), que usa focusBoundControl, y un método reset(), que verás en el apartado 17.5.
Con la LTS, touched se escribe y el blur cambia. Solo son estas líneas:
// src/app/lts/selector-estrellas-lts.ts
import { Component, model } from '@angular/core';
import { FormValueControl } from '@angular/forms/signals';
@Component({
selector: 'app-selector-estrellas-lts',
template: `
@for (n of [1, 2, 3, 4, 5]; track n) {
<button type="button" (click)="value.set(n)" (blur)="touched.set(true)">★</button>
}
`,
})
export class SelectorEstrellasLts implements FormValueControl<number> {
readonly value = model(0);
readonly touched = model(false);
}
⚠️ Importante: Este es uno de los cambios que no avisan al compilar.
touched = model(false)sigue compilando en 22, perotouched.set(true)ya no llega al campo; ytouch = output<void>()compila en 21, pero nadie lo escucha. En los dos casos el campo se queda sin marcar, y los errores no aparecen. Revisa tus controles propios al actualizar.
17.2. FormCheckboxControl
Si el control es una casilla o un interruptor, el modelo se llama checked, de tipo boolean. Es igual en las dos versiones, salvo por touch.
// src/app/comun/interruptor.ts
import { Component, input, model, output } from '@angular/core';
import { FormCheckboxControl } from '@angular/forms/signals';
@Component({
selector: 'app-interruptor',
template: `
<button
type="button"
role="switch"
[attr.aria-checked]="checked()"
[disabled]="disabled()"
(click)="checked.set(!checked())"
(blur)="touch.emit()"
>{{ checked() ? 'Sí' : 'No' }}</button>
`,
})
export class Interruptor implements FormCheckboxControl {
readonly checked = model(false);
readonly disabled = input(false);
readonly touch = output<void>();
}
17.3. transformedValue
A veces lo que se ve y lo que se guarda son cosas distintas. Queremos que el usuario vea y escriba 1250,50, y que el modelo guarde 1250.5. transformedValue crea una señal de texto que convierte en los dos sentidos:
// src/app/comun/importe-euros.ts
import { Component, input, model, output } from '@angular/core';
import { FormValueControl, transformedValue } from '@angular/forms/signals';
@Component({
selector: 'app-importe-euros',
template: `
<input
inputmode="decimal"
[value]="texto()"
[disabled]="disabled()"
(input)="texto.set($any($event.target).value)"
(blur)="touch.emit()"
/> €
`,
})
export class ImporteEuros implements FormValueControl<number | null> {
readonly value = model<number | null>(null);
readonly disabled = input(false);
readonly touch = output<void>();
protected readonly texto = transformedValue(this.value, {
parse: (crudo: string) => {
if (crudo.trim() === '') return { value: null };
const numero = Number(crudo.replaceAll('.', '').replace(',', '.'));
return Number.isNaN(numero)
? { error: { kind: 'parse', message: 'Escribe un importe, como 1.250,50' } }
: { value: numero };
},
format: (valor) =>
valor === null ? '' : valor.toLocaleString('es-ES', { minimumFractionDigits: 2 }),
});
}
parserecibe el texto y devuelve{ value }o{ error }. Con un error, el modelo no cambia.formatconvierte el valor del modelo en texto.- Dentro de un campo, el error llega solo a
errors()del campo. También está disponible entexto.parseErrors(). parseadmite también1.300,25, peroformatescribe1250,50sin punto: en español,Intlno separa los miles en números de cuatro cifras.

Figura 10. Tres controles propios conectados con [formField], igual que un <input>.
17.4. Controles propios en formularios clásicos 🆕 v22.0
🆕 Desde Angular 22.0. Un
FormValueControltambién funciona con[formControl]y[(ngModel)]. En la LTS (Angular 21) no es así, y estas directivas necesitan unControlValueAccessor. Si usas la LTS: si tu control debe servir en los dos mundos, implementaControlValueAccessorcomo en el módulo 17;FormFieldtambién sabe usarlo.
En sentido contrario, un componente que ya implemente ControlValueAccessor, como muchos de los de las bibliotecas actuales, funciona con [formField] en las dos versiones.
17.5. El método reset del control 🆕 v22.0
🆕 Desde Angular 22.0. En la LTS (Angular 21) nadie llama al método
reset()de un control propio. Si usas la LTS: no hay alternativa directa; si el control guarda estado interno, deriva ese estado devalue()con uncomputedpara que se actualice solo al cambiar el valor.
Si tu control tiene un método reset(), Angular lo llama cuando se restablece el campo con f().reset(). Sirve para limpiar lo que el control guarde por su cuenta, como el texto a medio escribir de un buscador.
18. Metadatos 🔄 v22.0
🔄 Cambia en Angular 22.0.
createManagedMetadataKeypasa a recibir(estado, datos), y la claveMINdeja de dar el número: en 22,metadata(MIN)da la clave elegida según el tipo del campo (MIN_NUMBERoMIN_DATE), y lo mismo ocurre conMAX. Si usas la LTS: encreateManagedMetadataKeyel único parámetro son los datos. Para leer límites usaf.x().min?.(), que funciona igual en 21 y 22;createLimitSelectionKeyno existe yhasMetadatano está en los tipos.
Las reglas no solo validan. También puedes adjuntar información a un campo para usarla en la interfaz, como un texto de ayuda. La clave se crea con createMetadataKey, se asigna con metadata y se lee en el estado del campo:
// src/app/metadatos/ayuda.ts
import { Component, signal } from '@angular/core';
import { createMetadataKey, form, FormField, metadata, min, required } from '@angular/forms/signals';
const AYUDA = createMetadataKey<string>();
@Component({
selector: 'app-ayuda',
imports: [FormField],
template: `
<label>Salario <input type="number" [formField]="f.salario" /></label>
<small>{{ f.salario().metadata(AYUDA)?.() }}</small>
<small> Mínimo: {{ f.salario().min?.() }}</small>
<small> Obligatorio: {{ f.salario().required() }}</small>
`,
})
export class Ayuda {
protected readonly AYUDA = AYUDA;
protected readonly datos = signal({ salario: null as number | null });
protected readonly f = form(this.datos, (p) => {
required(p.salario);
min(p.salario, 1000);
metadata(p.salario, AYUDA, () => 'Importe bruto mensual en euros');
});
}
Los validadores integrados publican sus límites como metadatos. Por eso puedes pintar «mínimo 1000» sin repetir el dato: el estado del campo los expone en required(), min(), max(), minLength(), maxLength() y pattern().
19. Convivir con formularios reactivos
No hace falta migrar una aplicación de golpe. El subpaquete @angular/forms/signals/compat tiene dos puentes, según en qué lado estés.
19.1. Un formulario nuevo que reutiliza un FormControl: compatForm
compatForm es como form, pero admite controles clásicos dentro del modelo. Sirve si tienes un FormControl con validadores complejos que no quieres reescribir aún:
// src/app/compat/ficha-compat.ts
import { Component, signal } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { FormField, required } from '@angular/forms/signals';
import { compatForm, SignalFormControl } from '@angular/forms/signals/compat';
@Component({
selector: 'app-ficha-compat',
imports: [FormField, ReactiveFormsModule],
template: `
<label>Nombre <input [formField]="f.nombre" /></label>
<label>Email (control clásico) <input [formField]="f.email" /></label>
<p>¿Válido?: {{ f().valid() }}, errores de email: {{ f.email().errors().length }}</p>
<form [formGroup]="grupo">
<label>Oficio (señal en un FormGroup) <input formControlName="oficio" /></label>
</form>
<p>¿Grupo válido?: {{ grupo.valid }}</p>
`,
})
export class FichaCompat {
// Un control clásico que ya tenías
private readonly email = new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.email],
});
protected readonly datos = signal({ nombre: '', email: this.email });
protected readonly f = compatForm(this.datos, (p) => {
required(p.nombre);
});
// Y al revés: reglas de Signal Forms dentro de un FormGroup clásico
protected readonly grupo = new FormGroup({
oficio: new SignalFormControl('', (p) => required(p)),
});
}
En el árbol, f.email se comporta como un campo normal. f.email().value() es el valor del control, y sus errores llegan como CompatValidationError, con el kind que da el validador clásico. Si necesitas el control original, lo tienes en f.email().control(). Las reglas de esquema no se pueden aplicar a estos campos, porque sus reglas siguen en el FormControl. El tipo SchemaPathRules.Unsupported lo impide al compilar.
19.2. Un FormGroup existente con una parte nueva: SignalFormControl
SignalFormControl hereda de AbstractControl, así que entra en un FormGroup como cualquier control. Por dentro, sus reglas son un esquema. Comprobado en las dos versiones: grupo.valid y grupo.value responden como siempre. Es la forma de migrar un formulario grande de abajo arriba, campo a campo.
| Estás en | Quieres | Usa |
|---|---|---|
| Signal Forms | Reutilizar un FormControl | compatForm |
| Reactivos | Añadir un campo con esquema | SignalFormControl |
| Migrando estilos | Clases ng-invalid, ng-touched… | provideSignalFormsConfig({ classes: NG_STATUS_CLASSES }) |
19.3. extractValue 🆕 v22.0
🆕 Desde Angular 22.0.
extractValueno existe en la LTS (Angular 21). Si usas la LTS: recorre el modelo a mano y lee cada control clásico con.value, o llama a.getRawValue().
El modelo de compatForm contiene objetos FormControl, que no se pueden enviar tal cual a una API. extractValue(f) devuelve los datos sin esos objetos. Con el segundo argumento filtra campos:
extractValue(f); // { nombre: 'Ana', email: 'ana@empresa.es' }
extractValue(f, { dirty: true }); // solo lo que ha cambiado el usuario
19.4. De reactivos a Signal Forms: equivalencias
| Reactivos | Signal Forms |
|---|---|
new FormGroup({ ... }) | form(signal({ ... })) |
control.value | campo().value() |
control.setValue(x) | campo().value.set(x) |
grupo.patchValue(...) | modelo.update((m) => ({ ...m, ... })) |
valueChanges | computed o effect sobre la señal |
Validators.required | required(p.campo) |
control.disable() | disabled(p.campo, { when: ... }) (en la LTS, disabled(p.campo, fn)) |
FormArray.push(...) | modelo.update(...) con otro array |
| Validador asíncrono | validateAsync, validateHttp |
control.hasError('min') | campo().getError('min') (en la LTS, errors().find(...)) |
grupo.reset() | f().reset(valorInicial) |
grupo.markAllAsTouched() | f().markAsTouched() (en la LTS solo marca la raíz) |
[formControl] | [formField] |
formGroup.invalid en (ngSubmit) | submit y FormRoot |
Hay un cambio de mentalidad detrás de esta tabla: en reactivos ordenas al control que haga algo, y en Signal Forms cambias los datos o declaras una regla.
20. Probar y depurar
20.1. Probar un esquema
Al estar separadas de la plantilla, las reglas se prueban sin montar ningún componente:
// src/app/alta-empleado/esquema-contacto.spec.ts
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { form, submit } from '@angular/forms/signals';
import { describe, expect, it } from 'vitest';
import { Contacto, esquemaContacto } from './esquema-contacto';
function crear(inicial: Contacto) {
return TestBed.runInInjectionContext(() => form(signal(inicial), esquemaContacto));
}
describe('esquemaContacto', () => {
it('exige un email', () => {
const f = crear({ email: '', telefono: '' });
expect(f.email().errors().map((e) => e.kind)).toEqual(['required']);
});
it('el teléfono es opcional, pero si se rellena debe tener nueve dígitos', () => {
const f = crear({ email: 'a@b.es', telefono: '' });
expect(f.telefono().valid()).toBe(true);
f.telefono().value.set('123');
expect(f.telefono().errors()[0].kind).toBe('pattern');
});
it('submit no ejecuta la acción si hay errores', async () => {
const f = crear({ email: '', telefono: '' });
let enviado = false;
const ok = await submit(f, async () => {
enviado = true;
});
expect(ok).toBe(false);
expect(enviado).toBe(false);
expect(f.email().touched()).toBe(true);
});
});
TestBed.runInInjectionContext proporciona el contexto de inyección que necesita form. Sobre pruebas tienes mucho más en el módulo 26.
20.2. Errores frecuentes
| Síntoma | Causa | Solución |
|---|---|---|
NG0203 al crear el formulario | form fuera de un contexto de inyección | Decláralo como propiedad, o pasa { injector } |
Can't bind to 'formField' | Falta FormField en imports | Añádelo |
Property 'x' does not exist en el árbol | Campo opcional en el modelo | Inicialízalo con null o '' |
TS2322 en [formField] | Tipo no admitido por el control | Mira la tabla de la sección 4 |
NG8022 | [disabled], [value] o required junto a [formField] | Pásalo al esquema |
| No aparecen los errores | El control propio no avisa de que se ha tocado | Mira la sección 17 |
| El campo oculto sigue en pantalla | hidden no quita nada | Añade @if (!f.x().hidden()) |
| La página se recarga al enviar | Falta FormRoot | Añade [formRoot] al <form> |
NG01915 | submit sin acción, o FormRoot sin submission en 21 | Declara submission |
| El botón de guardar no se activa | dirty no cambia al escribir por código | Usa markAsDirty() si lo necesitas |
reset() no borra el texto | reset no toca el valor | Usa reset(valorInicial) |
NaN o null inesperados | Campo number vaciado | Decláralo number \| null |
21. Qué compila en la LTS: tabla completa
Esta tabla reúne todas las diferencias. Cada línea se ha comprobado compilando y ejecutando en Angular 21.2.25 y 22.2.4. Todo lo que no aparece funciona igual en las dos: form, FormField, FieldTree, schema, apply, applyEach, applyWhen, applyWhenValue, required, email, minLength, maxLength, pattern, validate, validateTree, validateAsync, validateHttp, validateStandardSchema, debounce, submit, FormRoot, transformedValue, metadata, compatForm y SignalFormControl. Eso sí, en 21 son experimentales.
| Qué | Angular 21.2 | Angular 22.2 | Qué hacer en la LTS |
|---|---|---|---|
| Estado de la API | Experimental | Estable | Revisar las notas de cada versión |
hidden(p, fn) | Correcto | Obsoleto | Mantenerlo |
hidden(p, { when: fn }), readonly, disabled | TS2353 | Correcto | Pasar la función directamente |
hidden(p) sin condición | TS2554 | Desde 22.2 | hidden(p, () => true) |
when en email, min, max, pattern, etc. | TS2353 | Correcto | applyWhen |
when en required | Correcto | Correcto | |
min y max sobre string | Compila | TS2345 | Usar number \| null |
minDate, maxDate | No existen | Correcto | validate y getTime() |
getError | No existe | Correcto | errors().find(...) |
reloadValidation | No existe | Correcto | Señal contador en params |
hasMetadata | No está en los tipos | Correcto | Comprobar metadata(k) |
isFieldTree | No existe | Desde 22.1 | Acceder por nombre |
debounce y when en validadores asíncronos | TS2353 | Correcto | Regla debounce, undefined en request |
f().markAsTouched() en la raíz | Solo marca la raíz | Marca también a los hijos | Llamarlo en cada campo, o submit |
markAsTouched({ skipDescendants }) | No existe | Correcto | Es lo que ya hace en 21 |
Escribir en ctx.state y stateOf | Compila | TS2339 | No escribir desde reglas |
number \| null en <input type="text"> | TS2322 | Correcto | type="number" |
FormRoot sin submission | NG01915 al enviar | Correcto | Declarar submission |
Control propio: touched | model | input + touch | touched = model(false) |
Control propio: touched como output | Compila | TS2416 | Usar model |
Control propio: método reset() | No se llama | Se llama | Sin alternativa directa |
FormValueControl en [formControl] | No funciona | Correcto | ControlValueAccessor |
createManagedMetadataKey | (datos) | (estado, datos) | Un solo parámetro |
extractValue | No existe | Correcto | getRawValue() en cada control |
WithField, WithOptionalField, WithoutField | Obsoletos | Eliminados | WithFieldTree, etc. |
💡 Consejo: Para que el código de la LTS pase a 22 sin sorpresas, mantén las reglas siguientes: campos numéricos
number | null,applyWhenen vez de condiciones repetidas, nada de escribir desde las reglas, ysubmissionsiempre declarada. El único cambio masivo al actualizar será añadir{ when: ... }, y es mecánico.
22. Ejercicios
Todas las soluciones se han compilado y ejecutado contra la API EMPRESA.
Ejercicio 1: Formulario de departamento
Crea un componente NuevoDepartamento con los campos nombre, localidad y presupuesto. El nombre es obligatorio y tiene un máximo de 30 caracteres; presupuesto es opcional y no puede ser negativo. Muestra los errores con ErroresCampo.
Ver solución
// src/app/ejercicios/ej1.ts
import { Component, signal } from '@angular/core';
import { form, FormField, maxLength, min, required } from '@angular/forms/signals';
import { ErroresCampo } from '../comun/errores-campo';
@Component({
selector: 'app-nuevo-departamento',
imports: [FormField, ErroresCampo],
template: `
<label>Nombre <input [formField]="f.nombre" /></label>
<app-errores-campo [campo]="f.nombre" />
<label>Localidad <input [formField]="f.localidad" /></label>
<label>Presupuesto <input type="number" [formField]="f.presupuesto" /></label>
<app-errores-campo [campo]="f.presupuesto" />
`,
})
export class NuevoDepartamento {
protected readonly datos = signal({ nombre: '', localidad: '', presupuesto: null as number | null });
protected readonly f = form(this.datos, (p) => {
required(p.nombre, { message: 'El nombre es obligatorio' });
maxLength(p.nombre, 30, { message: 'Máximo 30 caracteres' });
min(p.presupuesto, 0, { message: 'No puede ser negativo' });
});
}
presupuesto es opcional porque min ignora el null, así que no hace falta nada más. Funciona igual en la LTS.
Ejercicio 2: Mensajes centralizados
Cambia ErroresCampo para que use mensajeDe (sección 8.2). Así, una regla sin message mostrará un texto útil en lugar de required.
Ver solución
// src/app/ejercicios/ej2.ts
import { Component, input } from '@angular/core';
import { Field } from '@angular/forms/signals';
import { mensajeDe } from '../comun/mensajes';
@Component({
selector: 'app-errores-campo-2',
template: `
@if (campo()().touched() && campo()().invalid()) {
<ul role="alert">
@for (error of campo()().errors(); track $index) {
<li>{{ mensaje(error) }}</li>
}
</ul>
}
`,
})
export class ErroresCampo2 {
readonly campo = input.required<Field<any>>();
protected readonly mensaje = mensajeDe;
}
Una plantilla solo puede usar lo que expone la clase, por eso mensaje guarda la función importada. Al tener mensajeDe en un único sitio, traducir o cambiar textos es trivial.
Ejercicio 3: Rango de salarios
Crea un filtro con desde y hasta (number | null). Si los dos tienen valor, hasta no puede ser menor que desde. El error debe aparecer en hasta, y debe desaparecer también al cambiar desde.
Ver solución
// src/app/ejercicios/ej3.ts
import { Component, signal } from '@angular/core';
import { form, FormField, validate } from '@angular/forms/signals';
import { ErroresCampo } from '../comun/errores-campo';
@Component({
selector: 'app-rango-salarios',
imports: [FormField, ErroresCampo],
template: `
<label>Desde <input type="number" [formField]="f.desde" /></label>
<label>Hasta <input type="number" [formField]="f.hasta" /></label>
<app-errores-campo [campo]="f.hasta" />
`,
})
export class RangoSalarios {
protected readonly datos = signal({ desde: null as number | null, hasta: null as number | null });
protected readonly f = form(this.datos, (p) => {
validate(p.hasta, ({ value, valueOf }) => {
const desde = valueOf(p.desde);
const hasta = value();
return desde !== null && hasta !== null && hasta < desde
? { kind: 'rango', message: 'Debe ser mayor o igual que el valor inicial' }
: null;
});
});
}
Al leer valueOf(p.desde), la regla depende de esa señal: cambiar desde la reevalúa sin escribir nada más.
Ejercicio 4: Cliente particular o empresa
Un formulario tiene tipo ('particular' o 'empresa'), nombre, nif y email. Si es empresa, nif es obligatorio y debe cumplir /^[A-Z][0-9]{8}$/. Si es particular, nif debe ocultarse. Resuélvelo de forma que compile en las dos versiones.
Ver solución
// src/app/ejercicios/ej4.ts
import { Component, signal } from '@angular/core';
import { applyWhen, form, FormField, pattern, required } from '@angular/forms/signals';
import { ErroresCampo } from '../comun/errores-campo';
@Component({
selector: 'app-tipo-cliente',
imports: [FormField, ErroresCampo],
template: `
<label><input type="radio" value="particular" [formField]="f.tipo" /> Particular</label>
<label><input type="radio" value="empresa" [formField]="f.tipo" /> Empresa</label>
<label>Nombre <input [formField]="f.nombre" /></label>
<app-errores-campo [campo]="f.nombre" />
@if (datos().tipo === 'empresa') {
<label>NIF <input [formField]="f.nif" /></label>
<app-errores-campo [campo]="f.nif" />
}
`,
})
export class TipoCliente {
protected readonly datos = signal({ tipo: 'particular', nombre: '', nif: '' });
protected readonly f = form(this.datos, (p) => {
required(p.nombre);
applyWhen(p, ({ value }) => value().tipo === 'empresa', (e) => {
required(e.nif, { message: 'El NIF es obligatorio' });
pattern(e.nif, /^[A-Z][0-9]{8}$/, { message: 'Letra y ocho dígitos' });
});
});
}
applyWhen no necesita { when } y sirve en 21 y 22. Aquí no hace falta hidden, porque las reglas solo se aplican a las empresas, y el @if ya controla la vista. Si el usuario escribe un NIF y vuelve a particular, ese dato sigue en el modelo; recuerda limpiarlo al enviar.
Ejercicio 5: NIF libre, también al editar
Valida que el NIF de un cliente no esté en uso con validateHttp y /api/clientes?q=.... Haz una función esquemaNif(clienteId) que no dé error si el NIF encontrado pertenece al cliente que estás editando.
Ver solución
// src/app/ejercicios/ej5.ts
import { Component, input, signal } from '@angular/core';
import { debounce, form, FormField, required, schema, validateHttp } from '@angular/forms/signals';
import { ErroresCampo } from '../comun/errores-campo';
import { API, Cliente, Pagina } from '../modelos';
export const esquemaNif = (clienteId: () => number | null) =>
schema<{ nif: string }>((p) => {
required(p.nif);
debounce(p.nif, 300);
validateHttp(p.nif, {
request: ({ value }) => `${API}/clientes?q=${encodeURIComponent(value())}`,
onSuccess: (r: Pagina<Cliente>, { value }) =>
r.contenido.some((c) => c.nif === value() && c.clienteId !== clienteId())
? { kind: 'nifOcupado', message: 'Ese NIF ya es de otro cliente' }
: null,
onError: () => null,
});
});
@Component({
selector: 'app-nif-cliente',
imports: [FormField, ErroresCampo],
template: `
<label>NIF <input [formField]="f.nif" /></label>
@if (f.nif().pending()) { <p>Comprobando...</p> }
<app-errores-campo [campo]="f.nif" />
`,
})
export class NifCliente {
readonly clienteId = input<number | null>(null);
protected readonly datos = signal({ nif: 'B12345678' });
protected readonly f = form(this.datos, esquemaNif(() => this.clienteId()));
}
Con [clienteId]="1001", el NIF B12345678 no da error porque es suyo; sin ese dato, da nifOcupado. Pasar una función en vez de un número hace que el esquema lea siempre el valor actual. Funciona igual en la LTS.
Ejercicio 6: Lista de teléfonos
Crea un formulario con una lista de teléfonos (string[]), con botones de añadir y quitar. Cada teléfono debe ser obligatorio y tener nueve dígitos. No puede haber más de tres.
Ver solución
// src/app/ejercicios/ej6.ts
import { Component, signal } from '@angular/core';
import { applyEach, form, FormField, maxLength, pattern, required } from '@angular/forms/signals';
import { ErroresCampo } from '../comun/errores-campo';
@Component({
selector: 'app-telefonos',
imports: [FormField, ErroresCampo],
template: `
@for (telefono of f.telefonos; track telefono; let i = $index) {
<input type="tel" [formField]="telefono" />
<button type="button" (click)="quitar(i)">Quitar</button>
<app-errores-campo [campo]="telefono" />
}
<app-errores-campo [campo]="f.telefonos" />
<button type="button" [disabled]="datos().telefonos.length >= 3" (click)="anadir()">Añadir</button>
`,
})
export class Telefonos {
protected readonly datos = signal({ telefonos: [''] });
protected readonly f = form(this.datos, (p) => {
maxLength(p.telefonos, 3, { message: 'Como mucho, tres teléfonos' });
applyEach(p.telefonos, (t) => {
required(t, { message: 'Escribe el teléfono' });
pattern(t, /^[0-9]{9}$/, { message: 'Nueve dígitos' });
});
});
protected anadir() {
this.datos.update((d) => ({ telefonos: [...d.telefonos, ''] }));
}
protected quitar(i: number) {
this.datos.update((d) => ({ telefonos: d.telefonos.filter((_, n) => n !== i) }));
}
}
maxLength funciona con todo lo que tenga length, también los arrays. Aquí t es la ruta del propio elemento, porque cada elemento es un string y no un objeto.
Ejercicio 7: Control contador
Crea Contador, un control propio de tipo FormValueControl<number> con botones - y +. Debe respetar min y max, si el campo los tiene, y disabled.
Ver solución
// src/app/ejercicios/ej7.ts
import { Component, input, model, output, signal } from '@angular/core';
import { form, FormField, max, min } from '@angular/forms/signals';
@Component({
selector: 'app-contador',
template: `
<button type="button" [disabled]="disabled() || value() <= (min() ?? -Infinity)" (click)="mover(-1)">-</button>
<output>{{ value() }}</output>
<button type="button" [disabled]="disabled() || value() >= (max() ?? Infinity)" (click)="mover(1)">+</button>
`,
})
export class Contador {
readonly value = model(0);
readonly disabled = input(false);
readonly min = input<number | undefined>(undefined);
readonly max = input<number | undefined>(undefined);
readonly touch = output<void>();
protected readonly Infinity = Infinity;
protected mover(paso: number) {
this.value.update((v) => v + paso);
this.touch.emit();
}
}
@Component({
selector: 'app-usa-contador',
imports: [FormField, Contador],
template: `<app-contador [formField]="f.unidades" />`,
})
export class UsaContador {
private readonly datos = signal({ unidades: 1 });
protected readonly f = form(this.datos, (p) => {
min(p.unidades, 1);
max(p.unidades, 3);
});
}
min y max del control se rellenan solos con los límites del esquema, sin repetirlos en la plantilla. En la LTS, cambia touch por touched = model(false), y this.touch.emit() por this.touched.set(true).
Ejercicio 8: Migrar un formulario reactivo
Reescribe en Signal Forms este formulario del módulo 17:
grupo = new FormGroup({
usuario: new FormControl('', { nonNullable: true, validators: [Validators.required] }),
edad: new FormControl<number | null>(null, [Validators.min(18)]),
});
guardar() {
if (this.grupo.invalid) { this.grupo.markAllAsTouched(); return; }
console.log(this.grupo.getRawValue());
}
Ver solución
// src/app/ejercicios/ej8.ts
import { Component, signal } from '@angular/core';
import { form, FormField, FormRoot, min, required } from '@angular/forms/signals';
@Component({
selector: 'app-migrado',
imports: [FormField, FormRoot],
template: `
<form [formRoot]="f">
<input [formField]="f.usuario" />
<input type="number" [formField]="f.edad" />
<button>Guardar</button>
</form>
`,
})
export class Migrado {
protected readonly datos = signal({ usuario: '', edad: null as number | null });
protected readonly f = form(this.datos, (p) => {
required(p.usuario);
min(p.edad, 18);
}, {
submission: {
action: async () => {
console.log(this.datos());
},
},
});
}
La comprobación de invalid, markAllAsTouched y getRawValue han desaparecido: lo primero y lo segundo lo hace submit, y el valor ya está en tu señal.
Ejercicio 9 (propuesto): Vigencia de un proyecto
Crea un formulario con inicio y fin de tipo Date | null, donde fin no pueda superar en más de un año a inicio. Resuélvelo primero con maxDate y una función, y después con validate para la LTS.
Ejercicio 10 (propuesto): Pedido real
Completa NuevoPedido: añade un <select> de clientes desde /api/clientes, y haz que action llame a POST /api/pedidos con { clienteId, lineas: [{ productoId, cantidad }] }, convirtiendo los identificadores a número. La API responde con un 409 si no hay stock; muestra ese mensaje en la raíz del formulario.
Ejercicio 11 (propuesto): Migración gradual
Coge un FormGroup de tres campos y sustituye uno por un SignalFormControl. Después convierte el formulario a compatForm, y al final a form. Comprueba que la plantilla apenas cambia en cada paso.
Resumen
| Concepto | API | Idea clave |
|---|---|---|
| Formulario | form(modelo, esquema, opciones) | Vista reactiva sobre una señal |
| Conexión | [formField], FormField | Enlaza el control y el campo en los dos sentidos |
| Campo y estado | f.x, f.x() | Sin paréntesis, el campo; con ellos, su estado |
| Validez | valid, invalid, pending | Tres estados, no dos |
| Limpiar | reset(valor) | Sin argumento no cambia los datos |
| Validadores | required, email, min, max, pattern… | Salvo required, ignoran el vacío |
| Fechas 🆕 | minDate, maxDate | En la LTS, validate |
| Reglas propias | validate, validateTree | valueOf crea dependencias entre campos |
| Disponibilidad 🔄 | disabled, hidden, readonly con { when } | En la LTS, función directa |
| Condición | applyWhen, applyWhenValue | Igual en 21 y 22 |
| Reutilizar | schema, apply | Reglas fuera del componente |
| Asíncrono | validateHttp, validateAsync, debounce | Solo tras pasar los síncronos |
| Zod | validateStandardSchema | Reglas compartidas con otras bibliotecas |
| Envío | submit, FormRoot, submission | La acción devuelve los errores del servidor |
| Listas | applyEach, @for ... track campo | Se modifica el modelo, no el árbol |
| Control propio | FormValueControl, FormCheckboxControl | Basta un model() llamado value |
| Conversión | transformedValue | Texto en la vista, dato en el modelo |
| Metadatos | metadata, createMetadataKey | Información extra para la interfaz |
| Compatibilidad | compatForm, SignalFormControl | Migrar por partes |