wandres.dev
FORMULARIOS COMO ESTADO · el caso difícil

Por qué los formularios son estado complejo

Un formulario parece la pieza de interfaz mas humilde que existe —tres campos y un boton— y es, sin embargo, una de las concentraciones de estado mas densas de todo el frontend. Bajo su apariencia trivial conviven al menos cinco dimensiones ortogonales: los valores que el usuario teclea, la validez de cada uno, la memoria de que campos ha tocado y cuales ha modificado, el estado del envio y los errores que devuelve el servidor. Esta leccion abre el nivel demoliendo el modelo mental ingenuo —el formulario como una bolsa de valores— y lo reemplaza por el marco que gobernara las cinco lecciones: un formulario es el producto de varios espacios de estado que no se derivan unos de otros, y por eso una libreria de formularios no es un adorno sino un gestor de estado especializado.

⏱ 18 min

Pocas piezas de interfaz parecen tan inofensivas como un formulario: unos campos, un boton y la promesa de que, al pulsarlo, algo se guardara. Esa inocencia es una trampa. Detras de cada campo no hay un dato sino cinco preguntas simultaneas —que vale ahora, si ese valor es valido, si el usuario ya lo toco, si lo ha cambiado respecto al inicial y en que punto del envio nos encontramos— y ninguna de ellas se deduce de las demas. Un formulario no es una bolsa de valores: es el producto cartesiano de varios espacios de estado que conviven sin fundirse. Este nivel entero trata de tomarse esa complejidad en serio, y arranca aqui, nombrando las dimensiones que casi todo el mundo confunde y explicando por que confundirlas es la raiz de la mayoria de los bugs de formulario que has sufrido.

🎯 Al terminar esta lección sabrás
  • Reconocer que un formulario no es una bolsa de valores, sino el producto de varias dimensiones de estado ortogonales.
  • Distinguir con precision valores, validez, touched, dirty y estado de envio, y por que ninguna se deriva de otra.
  • Separar el estado fuente del estado derivado dentro de un formulario y ver que confundirlos produce estados imposibles.
  • Adoptar el marco de “formulario como maquina de estado” que guiara las cinco lecciones del nivel.

Las cinco dimensiones que conviven

El modelo mental con el que casi todos empezamos es aritmetico: si un formulario tiene tres campos, tendra tres piezas de estado. Basta observar un formulario real en uso durante diez segundos para ver que la cuenta no cuadra. El usuario escribe, borra, salta de campo sin rellenarlo, corrige un error que le acaba de aparecer y, al pulsar enviar, se topa con que el servidor rechaza justo el correo que a su juicio era impecable. Cada una de esas escenas activa una dimension distinta del estado, y esas dimensiones no viven en el mismo eje.

La primera es la de los valores: lo que hay ahora mismo en cada campo. Es la unica que el modelo ingenuo reconoce, y por eso la unica que la mayoria modela bien. La segunda es la validez: si cada valor satisface sus reglas y, en caso contrario, con que mensaje. La validez no es un valor mas, es un juicio sobre el valor, y un mismo valor puede ser valido hoy e invalido cuando cambie la regla.

La tercera dimension es la memoria de la interaccion, que a su vez se desdobla: touched recuerda si el usuario llego a enfocar y abandonar un campo, y dirty recuerda si su valor difiere del inicial. La cuarta es el estado del envio: si el formulario esta ocioso, enviandose, si termino con exito o con fallo, y cuantas veces se ha intentado. La quinta son los errores del servidor, que llegan tarde, no los predice ninguna regla local y hay que fundir con los errores de validacion sin confundirlos.

⌨️

Valores

Lo que hay en cada campo ahora mismo. La unica dimension que el modelo ingenuo modela, y la fuente de la que cuelgan casi todas las demas.

Validez y errores

Un juicio sobre los valores, no un valor. Puede ser por campo o cruzada, y cambia cuando cambian las reglas aunque el valor siga igual.

👆

Touched y dirty

Memoria de la interaccion: que campos toco el usuario y cuales modifico respecto al inicial. No se deduce del valor actual.

🚀

Envio y servidor

Una maquina asincrona —ocioso, enviando, exito, fallo— mas los errores remotos que llegan tarde y hay que fundir con los locales.

💡
Touched y dirty no son lo mismo, y confundirlos se nota

La confusion mas comun entre las cinco dimensiones es tratar touched y dirty como sinonimos. No lo son. touched habla de atencion —el usuario enfoco el campo y lo abandono, escribiera o no—; dirty habla de cambio —el valor actual difiere del inicial—. Un campo puede estar tocado y limpio: entraste, saliste y no escribiste nada. Y puede estar sucio sin tocar: lo rellenaste por codigo. Cada uno sirve a una decision distinta: touched gobierna cuando mostrar un error sin agobiar, y dirty gobierna si hay cambios que valga la pena guardar o por los que avisar antes de abandonar la pagina. Fusionarlos en una sola bandera es perder justo la informacion que cada uno aporta por separado.

La ortogonalidad: por qué ninguna se deriva de otra

Dos dimensiones son ortogonales cuando saber el valor de una no te dice nada sobre la otra. Y ese es exactamente el caso aqui. Un campo puede estar valido y sucio y sin tocar —lo rellenaste por codigo—, o invalido y sin tocar —el usuario aun no ha llegado a el—, o valido pero con error del servidor —tu regla local lo aprobo y el backend lo rechazo—. Ninguna combinacion es absurda; todas ocurren. Por eso no puedes calcular touched a partir del valor, ni la validez a partir de dirty, ni el error del servidor a partir de nada que tengas en el cliente.

Merece la pena verlo desplegado, porque el modelo ingenuo colapsa justamente cuando estas combinaciones se cruzan. Cada fila de abajo es un estado legitimo que tu formulario tendra que representar y pintar de forma distinta, y ninguna se deduce de las otras:

valido   + sucio    + sin tocar    -> lo rellenaste por codigo o autocompletado
invalido + limpio   + sin tocar    -> el usuario aun no ha llegado a ese campo
valido   + sucio    + tocado       -> lo edito, lo abandono y quedo bien
valido   + limpio   + tocado       -> entro, salio y no cambio nada
valido en local     + error remoto -> tu regla lo aprobo, el servidor lo nego

Cinco dimensiones con dos o mas valores cada una generan un espacio de estados que crece de forma multiplicativa, no aditiva. Ese es el motivo profundo de que el modelo ingenuo —un useState por campo— no escale: no captura el espacio, solo una de sus proyecciones, la de los valores, y deja las otras cuatro dimensiones a merced de banderas sueltas que hay que sincronizar a mano.

De aqui sale la distincion mas importante de la leccion: dentro de un formulario hay estado fuente y estado derivado. Los valores, el touched, el contador de envios y los errores del servidor son fuente: nadie los calcula, entran desde fuera —del teclado, del foco, de la red—. En cambio los errores de validacion, el isDirty y el isValid son derivados: son una funcion pura del estado fuente. Almacenar un derivado como si fuera fuente es el pecado original del formulario ingenuo.

// El enfoque ingenuo: un useState por dato... y por cada metadato
const [email, setEmail] = useState("");
const [emailError, setEmailError] = useState<string | null>(null); // DERIVADO guardado como fuente
const [emailTouched, setEmailTouched] = useState(false);
const [emailDirty, setEmailDirty] = useState(false);               // DERIVADO guardado como fuente
// ...multiplica esto por cada campo, y suma isSubmitting, submitError, submitCount

El problema no es solo la verbosidad de cuatro useState por campo. Es que emailError y emailDirty son derivados y estan guardados como si fueran fuente, asi que ahora existen dos copias de la misma verdad: el valor real y su juicio congelado. En cuanto una se actualiza sin la otra —cambias email pero olvidas recomputar emailError— el formulario entra en un estado imposible: dice que el correo es invalido mientras muestra uno perfectamente valido. Ese desfase, y no la falta de librerias, es la causa profunda de los bugs de formulario.

Merece la pena ver el desfase desplegado en el tiempo, porque no es un fallo puntual sino una condicion de carrera latente. En el instante cero el correo esta vacio y emailError dice “requerido”; el usuario teclea una direccion valida y actualizas email, pero el manejador que recomputa emailError corre por otra rama, o mas tarde, o no corre. Durante esos milisegundos el formulario afirma dos cosas incompatibles a la vez, y si el usuario pulsa enviar justo entonces, rechazas un dato correcto. Nada de esto ocurre si el error no se almacena: al no existir la segunda copia, no hay nada que pueda desincronizarse.

La cura tiene nombre y es un principio de diseño, no un truco: hacer que los estados ilegales sean irrepresentables. Si el error no es un dato que guardas sino una funcion que calculas de los valores, el estado “valor valido con error de requerido” simplemente no cabe en tu modelo, igual que un statechart no puede estar en dos estados a la vez. El formulario robusto no es el que corrige a tiempo sus desfases, sino el que esta construido de modo que no puedan existir.

Queda una fuente silenciosa que conviene nombrar: los valores iniciales. El dirty no compara contra la nada, sino contra un punto de partida —los datos que llegaron del servidor al abrir el formulario—, y ese punto de partida es estado que hay que guardar aparte de los valores actuales. De el dependen dos gestos cotidianos: saber si hay cambios sin guardar y poder reiniciar el formulario a su estado original. Ignorarlo lleva al bug de un formulario que se cree sucio nada mas cargar, o que al reiniciarse vuelve a un vacio que nunca fue su verdadero inicio.

flowchart TD
V[valores fuente] --> E[errores derivados]
V --> D[dirty derivado]
T[touched fuente] --> UI[que mostrar y cuando]
E --> UI
S[maquina de envio] --> UI
R[errores del servidor] --> UI
style V fill:#89b4fa,color:#11111b
style T fill:#89b4fa,color:#11111b
style S fill:#cba6f7,color:#11111b
style R fill:#cba6f7,color:#11111b
style E fill:#a6e3a1,color:#11111b
style D fill:#a6e3a1,color:#11111b
style UI fill:#f9e2af,color:#11111b
💡
La regla que ordena todo el nivel: fuente arriba, derivado abajo

Ante cualquier pieza de estado de un formulario, hazte una sola pregunta: ¿esto entra desde fuera o lo puedo calcular? Si lo puedes calcular a partir de los valores y del esquema, es derivado y no debe almacenarse, solo computarse en el render. Los valores, el touched, el submitCount y los errores remotos son las unicas fuentes legitimas. Casi todo lo demas —isDirty, isValid, la lista de mensajes de error— es una funcion pura de esas fuentes. Cuando guardas un derivado, no ahorras trabajo: creas una segunda verdad que tarde o temprano se desincroniza de la primera.

El formulario como máquina de estado

Si observas la quinta dimension con atencion, veras que no es un puñado de banderas sueltas sino una maquina de estado honesta: se parte de ocioso, un envio lleva a enviando, y de ahi solo se puede salir a exito o a fallo, nunca a los dos a la vez. Es la misma disciplina de estados que estudiaste en el nivel de statecharts, aplicada ahora al ciclo de vida de un envio. Y el formulario completo es el producto de esa maquina por el espacio de los valores y por el de la validacion: tres subsistemas que evolucionan a la vez y que solo juntos describen “en que estado esta el formulario”.

Verlo asi tiene una consecuencia inmediata y liberadora. Una libreria de formularios no es una comodidad cosmetica que te ahorra teclear value y onChange: es un gestor de estado especializado que se encarga de las cuatro fuentes, computa por ti todos los derivados y —esto sera clave en la leccion cuatro— lo hace minimizando los re-render. Cuando en las proximas lecciones aparezcan Zod, React Hook Form o TanStack Form, no los leas como herramientas nuevas, sino como distintas respuestas a la pregunta que acabamos de plantear: como domar cinco dimensiones ortogonales sin crear estados imposibles.

Ese marco ordena el nivel entero. La leccion dos decide donde vive cada valor —en React o en el DOM— y mide el coste de esa eleccion. La tres declara la validez como un contrato del que los errores caen por gravedad, sin almacenarse. La cuatro presenta a las dos librerias que hacen de gestor de estado especializado. Y la cinco lleva el formulario hasta su frontera mas dificil, el envio, donde la maquina asincrona negocia con un servidor que puede decir que no. Todo el nivel es, en el fondo, este mismo diagrama de fuentes y derivados visto a distintas escalas.

📝
La densidad de estado no es un defecto: es la naturaleza del problema

Es tentador leer esta leccion como una lista de complicaciones a evitar, pero la densidad no es un accidente que una API mejor pueda eliminar: es intrinseca a lo que un formulario hace. Un formulario media entre la intencion difusa de un humano y el contrato estricto de un sistema, y esa mediacion exige recordar que se escribio, que es valido, que se toco y como fue el ultimo intento de guardar. Ninguna libreria borra esa complejidad; lo que hacen las buenas es colocarla donde corresponde —fuentes explicitas, derivados calculados— en lugar de dejarla dispersa en banderas. El objetivo del nivel no es que los formularios dejen de ser complejos, sino que dejes de sorprenderte por su complejidad y empieces a modelarla.

Un formulario es un gestor de estado disfrazado de tres cajitas

El salto conceptual que separa a quien sufre los formularios de quien los domina es dejar de verlos como interfaz y empezar a verlos como estado. Las tres cajitas y el boton son la punta visible; debajo hay un sistema con al menos cinco dimensiones ortogonales, una de ellas una maquina asincrona, y una frontera con el servidor que introduce errores que ninguna regla local predice. Quien no ha hecho este salto vive reinventando, mal, un gestor de estado en cada formulario: acumula useState sueltos, guarda derivados como si fueran fuente y pasa las tardes cazando desfases entre lo que el formulario cree y lo que muestra. Quien lo ha hecho reconoce de golpe por que existen las librerias de formularios, por que la validacion merece un esquema propio y por que el estado de envio pide integrarse con las mutaciones. La tesis del nivel cabe en una frase: un formulario no se maqueta, se modela. Y modelarlo empieza por aceptar que esas tres cajitas inocentes esconden mas estado del que casi ningun otro rincon de tu interfaz concentra en tan poco espacio.

⚔️ Diseca un formulario que ya tengas
  1. Toma un formulario real de tu aplicacion y escribe, campo por campo, cuales de las cinco dimensiones estas modelando de verdad y cuales estas ignorando.
  2. Clasifica cada pieza de estado que guardas como fuente o derivada, y marca las derivadas que estas almacenando por error como si fueran fuente.
  3. Busca en tu codigo un error o un dirty que actualices a mano y razona en que secuencia de eventos podria desincronizarse del valor real.
  4. Dibuja la maquina de estado del envio de ese formulario y comprueba que no puede estar en dos estados a la vez ni volver a ocioso sin querer.
  5. Enumera una combinacion de dimensiones que tu codigo permita representar pero que sea un estado imposible, y anota como la evitarias por construccion.
  6. Escribe en una frase por que “un useState por campo” no escala, y guardala: es el problema que las cuatro lecciones siguientes van a resolver.