Validación por paso: la guarda y el error como estado
Impedir que alguien avance con datos inválidos parece un problema de botones deshabilitados y termina siendo un problema de modelado. Esta lección sitúa la guarda como frontera real de la transición, explica por qué una guarda que rechaza en silencio es peor que ninguna, sustituye la bandera de error por una tercera situación con nombre —lo que todavía no se ha validado no es lo mismo que lo válido ni que lo inválido—, modela la validación asíncrona con su cancelación implícita y traza la frontera exacta entre lo que sabe el esquema y lo que decide la máquina.
La regla de negocio dice que no se puede pasar de la pantalla de datos personales sin un correo con forma de correo. La implementación habitual la traduce a un atributo que apaga el botón, y con eso cree haber terminado. No ha terminado: ha puesto un cartel donde hacía falta una puerta. El botón apagado no impide nada, solo lo desaconseja, y bastan una tecla intro, un formulario enviado por teclado, un recorrido de accesibilidad o un evento emitido desde otro sitio para que el avance ocurra igual. La frontera de un paso no está en su interfaz, está en su transición, y expresarla ahí tiene una consecuencia que va mucho más allá de la seguridad: obliga a decidir qué ocurre cuando alguien intenta cruzarla sin permiso, y esa pregunta es la que convierte el error en una situación del sistema en lugar de en una variable suelta.
- Situar la validación en la guarda de la transición y no en el estado visual del botón.
- Evitar el rechazo silencioso con una transición alternativa que lleve a un destino con nombre.
- Sustituir la bandera de error por tres situaciones distinguibles: sin validar, válido e inválido.
- Modelar la comprobación asíncrona contra el servidor con cancelación implícita al salir del estado.
La guarda es la frontera; el botón, solo su reflejo
Una guarda es un predicado puro que se evalúa antes de tomar una arista y decide si esa arista existe en este instante. Si devuelve falso, la transición no se toma: no hay salida, no hay entrada, no se ejecutan acciones y el estado no cambia. Esa negativa es absoluta y no depende de que la interfaz colabore, que es justo lo que la faltaba al botón deshabilitado.
guards: {
datosValidos: ({ event }) =>
event.type === 'SIGUIENTE' && esquemaDatos.safeParse(event.parche).success,
}
Con la guarda escrita, el botón deshabilitado no desaparece pero cambia de naturaleza: pasa de ser el mecanismo de defensa a ser una pista visual derivada de él. La forma limpia de calcularlo es preguntarle a la propia máquina con can si aceptaría el evento ahora mismo, de modo que no exista una segunda copia de la regla en la capa de presentación esperando a divergir de la primera.
Una guarda sin alternativa produce el peor comportamiento posible de cara al usuario: pulsa continuar y no ocurre absolutamente nada. Sin mensaje, sin foco movido, sin explicación. Desde dentro el modelo funcionó a la perfección —protegió su invariante—, y desde fuera la aplicación parece rota. Toda guarda que bloquee una acción deliberada del usuario necesita una arista hermana que recoja el caso contrario y lo lleve a algún sitio con nombre.
El error como estado, no como booleano
La corrección del rechazo silencioso obliga a inventar un destino para el intento fallido, y ahí aparece la pregunta interesante: qué es ese destino. La respuesta perezosa es una bandera en el contexto. La respuesta correcta es que cada paso es un estado compuesto con su propia vida interior, porque un formulario no tiene dos situaciones sino tres, y la tercera es la que ninguna bandera supo nunca expresar.
| Situación | Qué significa | Qué muestra la interfaz |
|---|---|---|
| sin validar | el usuario aún no ha intentado avanzar | ningún error, campos limpios |
| válido | el último intento pasó todas las comprobaciones | avance permitido |
| inválido | el último intento falló y sabemos por qué | mensajes junto a los campos culpables |
Un booleano solo distingue las dos últimas y aplasta la primera contra una de ellas, y de ahí salen las dos patologías clásicas de los formularios: o arranca marcando en rojo campos que nadie ha tocado todavía, o guarda silencio tras un intento fallido porque la bandera se inicializó en falso y nunca se levantó. El tipo que describe esto es sinValidar \| valido \| invalido, y la máquina lo obtiene gratis convirtiendo cada paso en compuesto.
Sin validar no es válido
Que aún no haya errores no significa que no los vaya a haber. Confundir ambas cosas produce el asistente que deja avanzar al que pulsa deprisa, porque la bandera de error seguía en falso desde el arranque.
El error señala a un campo
Un fallo útil no es un mensaje global sino una asociación entre campo y causa. Guardar un diccionario en el contexto permite pintar cada mensaje donde corresponde y mover el foco al primer culpable.
El esquema es una función pura
Lo que hace válido a un dato no depende de la máquina y por eso vive fuera: se prueba solo, se comparte con el servidor y se puede sustituir sin tocar un estado.
La guarda es la frontera
Deshabilitar es una pista; rechazar la transición es la garantía. Cuando ambas existen, la segunda es la verdad y la primera debe derivarse de ella con can.
datos: {
initial: 'editando',
states: {
editando: {},
invalido: {
on: { CAMBIO: { target: 'editando', actions: 'limpiarErrores' } },
},
},
on: {
SIGUIENTE: [
{ target: 'direccion', guard: 'datosValidos', actions: 'acumular' },
{ target: '.invalido', actions: 'anotarErrores' },
],
},
},
El orden del array es la clave de todo el patrón: la primera arista que encuentra su guarda satisfecha gana, y la última, sin guarda, actúa de red que atrapa cualquier intento que no cumplió. Ninguna pulsación se pierde, y el destino .invalido no es un adorno: es el estado donde la interfaz sabe que debe pintar mensajes, mover el foco al primer campo culpable y anunciar el fallo a un lector de pantalla.
La transición de invalido a editando ante un cambio implementa una política de usabilidad muy estudiada: no molestar antes del primer intento y, una vez que el usuario ya sabe que hay un problema, darle señal inmediata mientras lo corrige. Escrita como bandera esa política es un enredo de condiciones sobre campos tocados; escrita como dos subestados, es una arista.
Cuando la verdad está en el servidor
Hay validaciones que ningún esquema local puede resolver: si el correo ya está registrado, si el cupón sigue vivo, si el código postal existe. Esas comprobaciones son operaciones asíncronas y merecen un subestado propio entre el intento y el veredicto, porque durante ellas el sistema está en una situación real que la interfaz debe reflejar y que el usuario puede abandonar.
comprobando: {
invoke: {
src: 'correoLibre',
input: ({ context }) => ({ email: context.datos.email }),
onDone: [
{ target: '#alta.direccion', guard: ({ event }) => event.output.libre },
{ target: 'invalido', actions: 'anotarCorreoOcupado' },
],
onError: { target: 'invalido', actions: 'anotarFalloDeRed' },
},
on: { CAMBIO: 'editando' },
},
Aquí ocurre gratis lo que a mano cuesta más trabajo del que parece. Si el usuario sigue escribiendo mientras la consulta viaja, el evento CAMBIO saca la máquina de comprobando, y al salir del estado el actor invocado se detiene y su respuesta tardía se descarta sin llegar a aplicarse. Esa es la carrera que produce el bug más desconcertante de los formularios asíncronos: el mensaje de correo ocupado que aparece sobre un correo que ya se corrigió, porque una respuesta vieja llegó después de una nueva. No hay que escribir ninguna comprobación de vigencia: el ciclo de vida del actor está atado al ciclo de vida del estado, y eso basta.
stateDiagram-v2
state paso_datos {
[*] --> editando
editando --> comprobando: SIGUIENTE con forma valida
editando --> invalido: SIGUIENTE con forma invalida
comprobando --> invalido: ocupado o fallo de red
invalido --> editando: CAMBIO
comprobando --> editando: CAMBIO
}
paso_datos --> direccion: correo libreQué sabe el esquema y qué decide la máquina
La frontera entre ambos es fácil de enunciar y se cruza constantemente. El esquema sabe qué es válido: forma, longitud, rango, obligatoriedad, coherencia entre campos. La máquina decide cuándo se pregunta, qué consecuencia tiene la respuesta y a qué situación conduce cada desenlace. Meter el esquema dentro de la guarda como una cascada de condiciones escritas a mano rompe esa frontera y produce reglas que no se pueden probar en aislamiento ni reutilizar en el servidor; llamar desde la guarda a una función pura definida fuera la respeta, y deja la guarda como lo que debe ser, una línea legible.
const PASO_DE_CAMPO = {
nombre: 'datos',
email: 'datos',
calle: 'direccion',
ciudad: 'direccion',
tarjeta: 'pago',
} as const
export function pasoCulpable(errores: Record<string, string>) {
const campos = Object.keys(errores)
return campos.length === 0 ? null : PASO_DE_CAMPO[campos[0] as keyof typeof PASO_DE_CAMPO]
}
De esa separación sale además la respuesta al caso más molesto del patrón: el envío final rechazado por el servidor con errores que pertenecen a un paso anterior. Como los errores llegan asociados a campos y los campos pertenecen a pasos, una tabla de correspondencia permite que el fallo devuelva al usuario exactamente al paso culpable con el mensaje ya colocado, en lugar de dejarlo en la pantalla de revisión leyendo un error sobre un campo que no ve. Sin estados con nombre esa navegación de vuelta es aritmética adivinada; con ellos es una búsqueda en un diccionario.
Se enseña la guarda como un filtro, y esa metáfora se queda corta hasta el punto de ser engañosa. Un filtro es algo que se pone delante de una tubería que ya existe; una guarda es constitutiva de la arista, y por tanto de la topología del grafo. Decir que no se puede llegar a la pantalla de dirección sin un correo válido no es una restricción sobre un camino: es parte de la definición de qué es esa pantalla, porque el conjunto de configuraciones alcanzables define el espacio real del sistema con más precisión que la lista de estados declarados. De ahí se sigue la consecuencia fuerte: al escribir guardas no estás validando entradas, estás enunciando invariantes, afirmaciones sobre lo que necesariamente es cierto cuando el sistema se encuentra en un lugar determinado. Y un invariante enunciado en la arista es verificable, mientras que el mismo invariante repartido entre un atributo deshabilitado, una condición al enviar y una comprobación en el servidor no es verificable en ninguna parte, solo esperable. La segunda mitad de la lección es simétrica y menos evidente: si las guardas enuncian lo que debe ser cierto, los estados de error enuncian lo que ocurre cuando no lo es, y un sistema que solo tiene lo primero es un sistema que trata el fallo como ausencia. El fallo no es ausencia. Un formulario inválido no es un formulario al que le falta validez, es un formulario en una situación concreta, con causas concretas, con una interfaz propia y con salidas propias. Modelarlo como bandera es afirmar que ese estado no merece nombre; modelarlo como estado es reconocer que la mayor parte del tiempo que un usuario pasa en tu producto lo pasa exactamente ahí, intentando arreglar algo, y que esa parte merecía tanto diseño como el camino feliz.
- Busca en tu asistente todos los sitios donde un botón deshabilitado es la única defensa y anota qué ocurriría si el evento llegara igualmente.
- Escribe la guarda equivalente sobre la transición y haz que el botón derive su estado de
canen lugar de tener su propia condición. - Añade la arista hermana sin guarda que atrapa el intento fallido y llévala a un subestado inválido con los mensajes en el contexto.
- Comprueba que el primer render no muestra ningún error y que, tras un intento fallido, escribir vuelve a limpiar el estado.
- Modela una comprobación contra el servidor como subestado invocado y verifica que escribir durante la consulta descarta la respuesta tardía.
- Construye la tabla que asocia cada campo con su paso y haz que un rechazo del envío final devuelva al usuario al paso culpable con el error ya visible.