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

Controlado vs no controlado: React o el DOM

Todo campo de formulario guarda su valor en algun sitio, y solo hay dos sitios posibles: la memoria de React o el propio DOM. De esa eleccion, aparentemente tecnica, cuelga casi todo lo que importa en un formulario: quien es la fuente de verdad, si validas en cada tecla o solo al enviar, si puedes formatear la entrada al vuelo y —sobre todo— cuanto cuesta cada pulsacion en re-renders. Esta leccion define con rigor el campo controlado, cuyo valor vive en el estado de React y se sincroniza con `value` y `onChange`, frente al no controlado, cuyo valor vive en el DOM y se lee de forma perezosa con una `ref` o con `FormData`. Mide el coste de re-render de cada enfoque, explica por que el controlado ingenuo repinta el formulario entero en cada letra y por que ese coste, y no la ideologia, es la razon de que las librerias rapidas prefieran el modelo no controlado.

⏱ 17 min

Cada valor que un usuario teclea tiene que descansar en algun lugar entre pulsacion y pulsacion, y en React solo existen dos lugares posibles: dentro del estado del componente o dentro del propio nodo del DOM. Esa bifurcacion —tan pequeña que cabe en la eleccion entre escribir value o defaultValue— divide en dos el universo entero de los formularios. En un campo controlado, React es la unica fuente de verdad y cada tecla viaja hasta el estado y vuelve; en un campo no controlado, el DOM guarda el valor y React lo ignora hasta que alguien lo pide. La decision parece de fontaneria, pero determina si validas en vivo, si puedes enmascarar la entrada, y cuantos re-render pagas por cada letra. Esta leccion mide ese coste sin metaforas, porque es la clave que explica por que las librerias de formularios mas rapidas huyen del modelo controlado ingenuo.

🎯 Al terminar esta lección sabrás
  • Definir con precision el campo controlado —valor en React, value mas onChange— y el no controlado —valor en el DOM, leido con ref o FormData—.
  • Entender por que un formulario controlado ingenuo repinta todo su subarbol en cada pulsacion de tecla.
  • Medir el coste de re-render de cada enfoque y saber cuando ese coste importa y cuando es irrelevante.
  • Decidir con criterio que enfoque usar segun necesites validacion en vivo, formateo o simple rendimiento.

Dónde vive el valor

Un campo controlado entrega su valor a React y renuncia a tener memoria propia. Le pasas la prop value tomada del estado y un onChange que, en cada pulsacion, actualiza ese estado. El elemento del DOM ya no decide lo que muestra: lo que muestra es siempre un reflejo del estado de React. La fuente de verdad es una y esta en React, con toda la comodidad y todo el coste que eso implica.

// Controlado: el valor vive en React y el DOM solo lo refleja
function CampoControlado() {
  const [valor, setValor] = useState("");
  return (
    <input
      value={valor}
      onChange={(e) => setValor(e.target.value)}
    />
  );
}

Un campo no controlado hace lo contrario: deja que el valor viva en el DOM, como ha vivido siempre en HTML, y React se limita a plantar un valor inicial con defaultValue y a desentenderse. React no se entera de cada tecla; el valor solo se lee cuando hace falta, ya sea con una ref que apunta al nodo, ya sea a traves del objeto FormData que el navegador construye al enviar el formulario.

// No controlado: el valor vive en el DOM y se lee de forma perezosa
function CampoNoControlado() {
  const ref = useRef<HTMLInputElement>(null);
  const leer = () => console.log(ref.current?.value);
  return <input defaultValue="" ref={ref} onBlur={leer} />;
}

La diferencia de fondo no es sintactica sino de quien es la fuente de verdad. En el controlado, React manda y el DOM obedece en cada instante; en el no controlado, el DOM guarda la verdad y React solo la consulta cuando le interesa. Todo lo demas —la validacion, el formateo, el rendimiento— son consecuencias de esta unica decision.

⚛️

Controlado: la verdad en React

Prop value mas onChange, un setState por tecla. React conoce el valor en todo momento, con lo que puedes validar en vivo, formatear y revelar campos. El precio es un render por pulsacion.

🏷️

No controlado: la verdad en el DOM

Prop defaultValue mas ref o FormData. El navegador guarda el valor y React no se entera de cada tecla. Cero renders al escribir, pero lectura perezosa: React ignora el valor hasta que lo pide.

Conviene notar que la eleccion no es de todo o nada a nivel de formulario, sino campo por campo. Un mismo formulario puede tener un campo controlado para el importe que se formatea al vuelo y diez campos no controlados para el resto, que solo se leen al enviar. Pensar la fuente de verdad como una propiedad de cada campo, y no como una religion del formulario entero, es el primer signo de madurez en este terreno.

💡
No mezcles value y defaultValue en el mismo campo

Un campo es controlado o no controlado, nunca las dos cosas, y React protesta en cuanto uno cambia de bando en caliente. El origen habitual del aviso es inicializar value con undefined —porque el dato aun no llego del servidor— y luego darle una cadena: el campo nacio no controlado y se volvio controlado a mitad de vida. La regla es tajante: si controlas, arranca siempre con cadena vacia, nunca con undefined; si no controlas, usa defaultValue con una ref y no toques value. La mezcla no es un detalle cosmetico, es cambiar quien manda sobre el valor a media ejecucion, y React lo trata como el error de diseño que es.

El coste de re-render, medido sin metáforas

Aqui esta el nudo de la leccion. En un campo controlado, cada pulsacion dispara un setState, y cada setState dispara un render del componente que posee ese estado. Si —como suele ocurrir— ese estado vive en el componente raiz del formulario, teclear una sola letra vuelve a renderizar el formulario entero: los diez campos, sus etiquetas, sus mensajes de error, todo. El usuario escribe “hola” y React ha reconciliado el formulario cuatro veces completas.

flowchart LR
K[pulsacion de tecla] --> O[onChange]
O --> SS[setState en la raiz]
SS --> RR[render de todo el formulario]
RR --> DOM[reconciliacion de todos los campos]
style K fill:#89b4fa,color:#11111b
style SS fill:#f38ba8,color:#11111b
style RR fill:#f38ba8,color:#11111b
style DOM fill:#f9e2af,color:#11111b

Para un formulario de tres campos esto es invisible: un render de mas cada pocos milisegundos no lo nota nadie. El coste se vuelve real cuando el formulario es grande, cuando cada campo es un componente pesado —un selector con busqueda, un editor enriquecido— o cuando hay logica cara en el render. Entonces cada tecla arrastra un arbol entero y la escritura empieza a notarse con retardo. La leccion no es “el controlado es lento”, sino “el controlado paga un render por tecla, y ese precio se dispara con el tamaño”.

El campo no controlado no paga ese precio porque no hay setState por tecla: el DOM absorbe la escritura a su velocidad nativa y React ni se entera. La contrapartida es que React no conoce el valor hasta que lo pide, asi que pierdes la reaccion instantanea al contenido. De ese intercambio salen las dos estrategias que veras en la practica: aislar el estado controlado en el componente mas pequeño posible para que el render por tecla afecte a poco, o usar el modelo no controlado y leer los valores solo cuando de verdad importan.

Podria pensarse que las mejoras del propio React —el agrupado de actualizaciones, el renderizado concurrente— borran este coste, pero no lo hacen: reducen los renders redundantes y permiten interrumpirlos, no eliminan el trabajo de reconciliar un arbol grande en cada tecla cuando el estado vive arriba. El agrupado ayuda cuando varias actualizaciones caen juntas; aqui cada pulsacion es una unica actualizacion, y esa se paga entera. La arquitectura del formulario sigue mandando sobre el coste por encima de cualquier optimizacion del framework.

De ahi que la unica forma honesta de decidir sea medir, no intuir. Un profiler de renders te dira, para tu formulario concreto y tus componentes concretos, si el modelo controlado desde la raiz cuesta microsegundos imperceptibles o milisegundos que el usuario nota al escribir rapido. La regla practica es no reescribir por sospecha: mide primero, localiza si el problema era la altura del estado o de verdad el modelo, y solo entonces cambia lo justo.

⚠️
El coste no es el controlado, es dónde pones el estado

Es facil sacar la conclusion equivocada de que “controlado es igual a lento”. Lo que es caro no es controlar, sino controlar desde demasiado arriba. Un useState en la raiz del formulario convierte cada tecla en un render de todo el arbol; ese mismo useState encapsulado en un componente Campo diminuto convierte cada tecla en un render de ese unico campo. La colocacion del estado —el mismo principio de minimizar y aislar que viste en los primeros niveles— decide el coste real. Antes de reescribir un formulario a no controlado por rendimiento, comprueba si el problema era simplemente que tenias el estado tres componentes mas arriba de donde debia estar.

Qué gana cada enfoque y hacia dónde va React

Cada modelo compra una cosa distinta. El controlado compra reactividad: como React conoce el valor en cada instante, puedes validar en vivo, habilitar o deshabilitar el boton segun el contenido, enmascarar y formatear la entrada mientras se teclea —un telefono, un importe—, o mostrar y ocultar campos en funcion de lo que ya se escribio. Todo eso exige que React sepa el valor ahora, no al enviar. El no controlado compra rendimiento y sencillez: menos codigo, cero renders por tecla y una lectura perezosa que encaja de maravilla cuando el formulario solo necesita sus valores al final.

Hay ademas un campo que no admite eleccion: el de tipo file. Por seguridad, el navegador no deja que ningun script fije el valor de un selector de archivos —seria abrir la puerta a subir ficheros sin que el usuario los elija—, asi que un campo de archivo es siempre no controlado y solo se lee, nunca se escribe. Es el recordatorio mas claro de que la eleccion entre controlar y no controlar no siempre es tuya: a veces la plataforma ya decidio, y tu modelo mental debe acomodarse a ella en lugar de pelearse.

Los controles que no son texto —casillas, radios, selectores— tienen su propia version del dilema. Un checkbox controlado lee su checked del estado en vez de su value, y un grupo de radios comparte un unico valor controlado; en su version no controlada, el DOM guarda que hay marcado y tu lo lees al final. El principio es identico —donde vive la verdad—, pero conviene saber que cada tipo de control lo expresa con una prop distinta, y que confundirlas es una fuente habitual de campos que parecen no responder al usuario.

Esta tension explica, por adelantado, la leccion cuatro. React Hook Form es rapido precisamente porque adopta el modelo no controlado como base —registra los campos con ref en lugar de controlarlos— y solo re-renderiza los componentes que se suscriben a un campo concreto. No es magia: es la eleccion de este capitulo llevada a libreria. Y el propio React empuja en la misma direccion: las Server Actions y el patron de FormData en el envio recuperan la vieja virtud del formulario no controlado, dejando que el navegador recoja los valores y reservando el estado de React para lo que de verdad necesita ser reactivo.

Hay ademas un matiz de honestidad intelectual que conviene no perder. La reactividad del campo controlado no exige que el estado viva arriba: exige que exista en React, en el nivel mas bajo posible. Por eso la falsa dicotomia “controlado igual a lento, no controlado igual a rapido” se disuelve en cuanto separas dos preguntas que suelen ir juntas por pereza —donde vive el valor y en que altura del arbol vive—. La primera decide que puedes hacer con el; la segunda decide cuanto cuesta. Confundirlas es lo que lleva a reescribir formularios enteros a no controlado cuando bastaba con mover un useState un piso mas abajo.

📝
En 2026 el pendulo vuelve hacia el no control, pero por razones nuevas

Durante años el consejo por defecto fue controlar todo, porque la reactividad valia mas que unos renders de sobra. El pendulo vuelve hoy hacia el modelo no controlado, pero no por nostalgia: las Server Actions de React tratan el formulario como una unidad que se envia con FormData, y useActionState y useFormStatus modelan el envio sin necesidad de controlar cada campo en el cliente. La leccion no es que ahora toque no controlar siempre, sino que el ecosistema ha vuelto a valorar la lectura perezosa donde no hace falta reactividad, y que conviene reservar el control para los campos que de verdad reaccionan mientras se escriben.

Elige por el valor, no por la costumbre: reactividad contra coste

La madurez en formularios no consiste en elegir siempre controlado ni siempre no controlado, sino en saber que cada campo plantea la misma pregunta y merece su propia respuesta: ¿necesito que React conozca este valor mientras se teclea, o me basta con leerlo al final? Si la respuesta es que lo necesito en vivo —para validar, formatear o revelar campos—, el campo pide ser controlado y aceptas su render por pulsacion, colocando el estado lo mas abajo posible para que ese render sea barato. Si la respuesta es que solo lo necesito al enviar, controlarlo es pagar un impuesto de renders a cambio de nada, y el modelo no controlado es la eleccion honesta. El error caro no es preferir uno u otro: es aplicar el mismo reflejo a los diez campos sin preguntarse por ninguno. Quien entiende que la fuente de verdad puede vivir en React o en el DOM, y que esa unica decision arrastra la validacion, el formateo y el coste, deja de copiar plantillas y empieza a diseñar formularios que escalan. Las librerias de la leccion cuatro no inventan un tercer lugar donde guardar el valor: eligen sabiamente entre estos dos y automatizan las consecuencias.

⚔️ Mide el coste de cada modelo con tus manos
  1. Construye un formulario de diez campos controlados con el estado en la raiz y cuenta, con un contador de renders, cuantas veces se repinta al teclear una palabra en un campo.
  2. Baja ese estado a un componente Campo individual y vuelve a medir: observa como el render por tecla deja de arrastrar a los otros nueve campos.
  3. Reescribe el mismo formulario en version no controlada con defaultValue y ref, leyendo los valores solo en el envio, y compara los renders con el paso anterior.
  4. Añade a un campo un formateo en vivo —un importe con separadores de miles— y comprueba por que ese requisito obliga a que el campo sea controlado.
  5. Lee un formulario no controlado con FormData en el envio y anota que ganaste en simplicidad y que perdiste en reactividad.
  6. Escribe, para un formulario tuyo real, que campos de verdad necesitan ser controlados y cuales podrian ser no controlados sin que nadie lo note.