Escenarios de bugs de formularios e inputs
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.
Cómo usar esta lista
- 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.
1. "No puedo escribir en el campo de email del formulario de registro"
"Intenté registrarme. El campo de email no acepta ningún input: pulso teclas y no pasa nada. Los demás campos funcionan."
Diagnóstico: El input tiene la prop value pero no onChange, lo que lo convierte en un input controlado de solo lectura.
Solución:
"use client";
import { useState } from "react";
export function EmailField() {
const [email, setEmail] = useState("");
return (
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
);
}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.
2. "Al hacer clic en Enviar se recarga la página y pierdo todas mis respuestas"
"Rellenando la encuesta, pulsé Enviar al final, la página entera se recarga y mis respuestas desaparecen."
Diagnóstico: Un <form> sin onSubmit recurre al envío nativo del formulario, lo que navega el navegador a la action del formulario (o a la URL actual).
Solución:
"use client";
import { FormEvent, useState } from "react";
export function Survey() {
const [name, setName] = useState("");
const onSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
saveSurvey({ name });
};
return (
<form onSubmit={onSubmit}>
<input value={name} onChange={(e) => setName(e.target.value)} />
<button type="submit">Enviar</button>
</form>
);
}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.
3. "Cuando escribo rápido en el campo de búsqueda el cursor salta al final del texto"
"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-renderizan
function 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.
4. "El formulario se borra cuando envío un email inválido: se borran todos los demás campos"
"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.
Solución:
"use client";
import { useForm } from "react-hook-form";
interface CheckoutForm {
email: string;
name: string;
address: string;
}
export function Checkout() {
const { register, handleSubmit, formState: { errors } } = useForm<CheckoutForm>({
defaultValues: { email: "", name: "", address: "" },
});
return (
<form onSubmit={handleSubmit((data) => save(data))}>
<input {...register("email", { pattern: /^[^@]+@[^@]+$/ })} />
{errors.email && <p>Email inválido</p>}
<input {...register("name")} />
<input {...register("address")} />
<button type="submit">Pagar</button>
</form>
);
}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.
5. "El checkbox 'Recuérdame' siempre se envía como marcado aunque lo desmarque"
"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.
Solución:
"use client";
import { useState } from "react";
export function RememberMe() {
const [remember, setRemember] = useState(false);
return (
<label>
<input
type="checkbox"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
/>
Recuérdame
</label>
);
}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.
6. "El campo 'edad' se guarda como texto en lugar de número y nuestro filtro de edad falla"
"Soporte dice que el campo de edad acepta texto. Los informes muestran edades como '42' entre comillas: los filtros que esperan números fallan."
Diagnóstico: <input type="number"> sigue devolviendo una cadena desde e.target.value. El valor debe convertirse explícitamente.
Solución:
"use client";
import { useState } from "react";
export function AgeField() {
const [age, setAge] = useState<number | null>(null);
return (
<input
type="number"
value={age ?? ""}
onChange={(e) => {
const v = e.target.valueAsNumber;
setAge(Number.isNaN(v) ? null : v);
}}
/>
);
}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.
7. "Las fechas de nacimiento aparecen con un día de menos en el registro del cliente"
"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.
8. "Pulsar Enter en la barra de búsqueda dispara la búsqueda dos veces"
"Barra de búsqueda arriba: si pulso Enter, la pestaña de red muestra dos peticiones con la misma consulta."
Diagnóstico: Tanto onKeyDown (Enter) como el envío implícito del formulario se disparan, cada uno activando su propia llamada de búsqueda.
Solución:
"use client";
import { FormEvent, useState } from "react";
export function SearchBar() {
const [q, setQ] = useState("");
const onSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
search(q);
};
return (
<form onSubmit={onSubmit}>
<input value={q} onChange={(e) => setQ(e.target.value)} />
<button type="submit">Buscar</button>
</form>
);
}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.
9. "Los campos deshabilitados no se incluyen en los datos que guardamos"
"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.
10. "La barra de progreso de subida de archivos nunca se mueve: salta de 0% a 100%"
"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.
Solución:
"use client";
export function uploadWithProgress(file: File, onProgress: (pct: number) => void): Promise<void> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
});
xhr.addEventListener("load", () => (xhr.status < 400 ? resolve() : reject(new Error(xhr.statusText))));
xhr.addEventListener("error", () => reject(new Error("Network error")));
xhr.open("POST", "/api/upload");
const fd = new FormData();
fd.append("file", file);
xhr.send(fd);
});
}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.
Relacionado
- Fundamentos de Formularios y Validación — Patrones fundamentales de formularios.
- React Hook Form — Patrones específicos de la librería.
- Las Mejores Prácticas de React Events — Tipado y comportamiento de manejadores de eventos.