Diez tickets de bugs reales sobre comportamiento de formularios, controles de input, validación y envío. Cada entrada muestra el ticket no técnico, el diagnóstico, una solución concisa y por qué la solución es correcta.
Los bugs de formularios se agrupan en torno a cuatro causas: falta de onChange (controlado), firma incorrecta del manejador, tipo de input incorrecto y valores por defecto ausentes.
Comprueba siempre si el input es controlado, no controlado o híbrido: la mayoría de bugs de formularios se esconden en ese límite.
Por qué funciona: React requiere tanto value como onChange para inputs controlados. onChange es lo que muta el state en cada pulsación de tecla, lo que provoca un re-renderizado del input con el nuevo value.
Por qué funciona:e.preventDefault() cancela el envío nativo, así que el navegador no recarga. El manejador ejecuta tu lógica de guardado con el state actual del formulario en lugar de tirar los datos.
"Edito un nombre en medio, escribo un carácter y el cursor salta al final. Nos está volviendo locos al equipo."
Diagnóstico: El input se desmonta y vuelve a montar en cada pulsación de tecla, normalmente porque está definido dentro de un componente padre que se re-renderiza o por un key que cambia.
Solución:
"use client";// Define componentes estables FUERA de los padres que se re-renderizanfunction NameInput({ value, onChange }: { value: string; onChange: (v: string) => void }) { return <input value={value} onChange={(e) => onChange(e.target.value)} />;}export function Page() { const [name, setName] = useState(""); return <NameInput value={name} onChange={setName} />;}
Por qué funciona: Los componentes definidos dentro de otro componente se recrean en cada renderizado, así que React los trata como tipos nuevos y desmonta/vuelve a montar el input, perdiendo el state de selección. Definir el componente a nivel de módulo mantiene la instancia del input estable entre renderizados.
"Formulario grande de checkout. Si el email es inválido mostramos el error, pero todos los demás campos se vacían a la vez."
Diagnóstico: El formulario usa defaultValue en inputs no controlados sin defaultValues de useForm, y luego se re-renderiza con nuevas keys o se remonta en la validación, borrando el state del DOM.
Por qué funciona: React Hook Form posee el state de los campos en un almacén respaldado por refs, así que los errores de validación provocan re-renderizados sin remontar inputs ni perder sus valores. Los valores de los campos sobreviven a cada ciclo de validación.
"El 'Recuérdame' del formulario de login está atascado. El state lo muestra desmarcado visualmente pero la API siempre recibe true."
Diagnóstico: El checkbox está vinculado con value en lugar de checked, así que React renderiza el atributo value como cadena pero nunca controla realmente el state marcado.
Por qué funciona: Los checkboxes usan checked y e.target.checked, no value y e.target.value. El atributo value de un checkbox es la cadena literal que se envía al hacer submit cuando está marcado, no si está seleccionado.
Por qué funciona:valueAsNumber devuelve un número o NaN directamente. Guardar null cuando el campo está vacío evita convertir cadenas vacías en 0 y coincide con lo que una API JSON espera para un valor ausente.
"Elegimos una fecha de nacimiento en el selector, la guardamos y la página de detalle del cliente muestra el día anterior. Se reproduce para todos."
Diagnóstico:new Date("1990-05-14") se analiza como medianoche UTC, pero toLocaleDateString() renderiza en hora local, que es el día anterior en cualquier zona horaria al oeste de UTC.
Solución:
export function formatBirthday(iso: string): string { const [y, m, d] = iso.split("-").map(Number); return new Date(y, m - 1, d).toLocaleDateString();}
Por qué funciona: Construir el Date con argumentos numéricos lo crea en hora local, coincidiendo con la fecha que el usuario eligió realmente. Las cadenas ISO implican UTC y se desplazan silenciosamente entre límites de zona horaria; el caso de fecha de calendario necesita un analizador de fecha de calendario.
Por qué funciona: Pulsar Enter dentro de un formulario ya lo envía; no hace falta escuchar Enter por separado. Quitar el manejador onKeyDown y apoyarse en onSubmit hace que la única ruta de envío sea la única ruta.
"Deshabilitamos ciertos campos una vez 'bloqueados'. Al guardar, esos campos faltan en el cuerpo de la petición aunque se vean."
Diagnóstico:FormData nativo omite los inputs disabled por diseño: no se consideran parte del envío del formulario.
Solución:
// Opción A:<input name="planId" value={planId} readOnly />// Opción B: incluir el valor explícitamente al construir el payload:const payload = { ...Object.fromEntries(new FormData(form)), planId };
Por qué funciona:readOnly mantiene el campo no editable pero aún enviable, mientras que disabled lo elimina por completo de FormData. Si la UX necesita disabled, el manejador de envío debe fusionar manualmente los valores que faltan.
"Los clientes pueden adjuntar archivos pero la barra de progreso está rota. Se queda en 0% y luego salta a 100% cuando termina la subida."
Diagnóstico: La subida usa fetch, que no expone eventos de progreso del lado de la petición. Solo XMLHttpRequest (o una abstracción consciente de streaming) informa los bytes subidos.
Por qué funciona:xhr.upload.progress es la única API del navegador que informa bytes enviados del lado de la petición. Los cuerpos de petición ReadableStream de fetch no exponen progreso de forma multiplataforma hoy, así que XHR sigue siendo la herramienta adecuada para UIs de progreso de subida.