Eventos de Formularios
Maneja envíos de formularios, cambios de entrada y restablecimientos de formularios en componentes de React.
Referencia de Eventos de Formularios
| Propiedad de React | Tipo TypeScript | Se dispara cuando | Notas |
|---|---|---|---|
onChange | React.ChangeEvent<T> | El valor de un input, select o textarea cambia | En React, se dispara en cada pulsación de tecla (no solo en blur) |
onInput | React.FormEvent<T> | El valor de un input cambia | Casi idéntico a onChange en React -- prefiere onChange |
onSubmit | React.FormEvent<HTMLFormElement> | Se envía el formulario (tecla Enter o botón submit) | Llama a e.preventDefault() para manejo en el cliente |
onReset | React.FormEvent<HTMLFormElement> | Se hace clic en el botón de restablecimiento del formulario | Raramente usado -- la mayoría de aplicaciones manejan el restablecimiento mediante state |
onInvalid | React.FormEvent<T> | Falla la validación integrada en un input | Se dispara antes de que el navegador muestre su tooltip de validación |
Receta
Tarjeta de referencia rápida -- lista para copiar y pegar.
// Input controlado con onChange
function ControlledInput() {
const [name, setName] = React.useState("");
return (
<input
value={name}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setName(e.target.value)}
placeholder="Ingresa el nombre"
/>
);
}
// Envío de formulario con preventDefault
function SimpleForm() {
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
console.log("Email:", formData.get("email"));
};
return (
<form onSubmit={handleSubmit}>
<input name="email" type="email" required />
<button type="submit">Enviar</button>
</form>
);
}
// Extracción de FormData sin estado controlado
function UncontrolledForm() {
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const data = Object.fromEntries(new FormData(e.currentTarget));
console.log(data); // { email: "...", password: "..." }
};
return (
<form onSubmit={handleSubmit}>
<input name="email" type="email" />
<input name="password" type="password" />
<button type="submit">Inicia sesión</button>
</form>
);
}Cuándo usarlo: Necesitas manejar la entrada del usuario en formularios -- campos de texto, selects, casillas de verificación, cargas de archivos o envíos de formularios con validación.
Ejemplo Funcional
"use client";
import { useState, useCallback } from "react";
type FormData = {
username: string;
email: string;
password: string;
role: string;
agree: boolean;
};
type FormErrors = Partial<Record<keyof FormData, string>>;
const INITIAL_STATE: FormData = {
username: "",
email: "",
password: "",
role: "",
agree: false,
};
function validate(data: FormData): FormErrors {
const errors: FormErrors = {};
if (data.username.length < 3) errors.username = "Al menos 3 caracteres";
if (!data.email.includes("@")) errors.email = "Dirección de correo no válida";
if (data.password.length < 8) errors.password = "Al menos 8 caracteres";
if (!data.role) errors.role = "Selecciona un rol";
if (!data.agree) errors.agree = "Debes aceptar los términos";
return errors;
}
export default function SignupForm() {
const [formData, setFormData] = useState<FormData>(INITIAL_STATE);
const [errors, setErrors] = useState<FormErrors>({});
const [submitted, setSubmitted] = useState(false);
const handleChange = useCallback(
(
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => {
const { name, type } = e.target;
const value =
type === "checkbox"
? (e.target as HTMLInputElement).checked
: e.target.value;
setFormData((prev) => ({ ...prev, [name]: value }));
// Limpia el error de este campo al cambiar
setErrors((prev) => ({ ...prev, [name]: undefined }));
},
[]
);
const handleSubmit = useCallback(
(e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const validationErrors = validate(formData);
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
console.log("Datos de registro:", formData);
setSubmitted(true);
},
[formData]
);
const handleReset = useCallback(() => {
setFormData(INITIAL_STATE);
setErrors({});
setSubmitted(false);
}, []);
const handleInvalid = useCallback(
(e: React.FormEvent<HTMLInputElement>) => {
e.preventDefault(); // Previene el tooltip del navegador
const name = e.currentTarget.name as keyof FormData;
setErrors((prev) => ({
...prev,
[name]: e.currentTarget.validationMessage,
}));
},
[]
);
if (submitted) {
return (
<div style={{ padding: 24 }}>
<p>Cuenta creada para {formData.username}.</p>
<button onClick={handleReset}>Registra otro</button>
</div>
);
}
return (
<form
onSubmit={handleSubmit}
onReset={handleReset}
noValidate
style={{
maxWidth: 400,
display: "flex",
flexDirection: "column",
gap: 16,
padding: 24,
}}
>
<h2 style={{ margin: 0 }}>Registrarse</h2>
<div>
<label htmlFor="username">Nombre de usuario</label>
<input
id="username"
name="username"
value={formData.username}
onChange={handleChange}
onInvalid={handleInvalid}
required
minLength={3}
style={{ display: "block", width: "100%", padding: "8px" }}
/>
{errors.username && (
<span style={{ color: "#dc2626", fontSize: 14 }}>
{errors.username}
</span>
)}
</div>
<div>
<label htmlFor="email">Correo</label>
<input
id="email"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
onInvalid={handleInvalid}
required
style={{ display: "block", width: "100%", padding: "8px" }}
/>
{errors.email && (
<span style={{ color: "#dc2626", fontSize: 14 }}>
{errors.email}
</span>
)}
</div>
<div>
<label htmlFor="password">Contraseña</label>
<input
id="password"
name="password"
type="password"
value={formData.password}
onChange={handleChange}
onInvalid={handleInvalid}
required
minLength={8}
style={{ display: "block", width: "100%", padding: "8px" }}
/>
{errors.password && (
<span style={{ color: "#dc2626", fontSize: 14 }}>
{errors.password}
</span>
)}
</div>
<div>
<label htmlFor="role">Rol</label>
<select
id="role"
name="role"
value={formData.role}
onChange={handleChange}
style={{ display: "block", width: "100%", padding: "8px" }}
>
<option value="">Selecciona un rol...</option>
<option value="developer">Desarrollador</option>
<option value="designer">Diseñador</option>
<option value="manager">Gerente</option>
</select>
{errors.role && (
<span style={{ color: "#dc2626", fontSize: 14 }}>
{errors.role}
</span>
)}
</div>
<div>
<label>
<input
name="agree"
type="checkbox"
checked={formData.agree}
onChange={handleChange}
/>{" "}
Acepto los términos
</label>
{errors.agree && (
<span style={{ color: "#dc2626", fontSize: 14, display: "block" }}>
{errors.agree}
</span>
)}
</div>
<div style={{ display: "flex", gap: 8 }}>
<button type="submit" style={{ padding: "8px 16px" }}>
Crear Cuenta
</button>
<button type="reset" style={{ padding: "8px 16px" }}>
Restablecer
</button>
</div>
</form>
);
}Lo que esto demuestra:
- Inputs controlados con un único
handleChangepara texto, correo, contraseña, select y casilla de verificación - Validación personalizada ejecutada al enviar con visualización de errores por campo
- Uso de
onInvalidpara interceptar la validación del navegador y mostrar mensajes de error personalizados - Uso de
onResetpara limpiar el estado del formulario a los valores iniciales - Tipificación TypeScript para estado del formulario, estado de errores y manejadores de eventos
- Manejo de
checkeden casillas de verificación vsvalueen inputs de texto en un solo manejador con narrowing de tipos
Análisis Profundo
Cómo Funciona
- El
onChangede React se dispara en cada pulsación de tecla para inputs de texto, lo que difiere del eventochangedel DOM nativo (que se dispara en blur). Esto hace que los inputs controlados sean reactivos y permite validación en tiempo real. onSubmitse dispara cuando se envía el formulario a través de la tecla Enter (mientras se enfoca en un input) o haciendo clic en un botóntype="submit". Siempre llama ae.preventDefault()para manejo en el cliente para evitar una recarga de página completa.onInvalidse dispara cuandoform.reportValidity()oform.requestSubmit()dispara validación y un input falla sus restricciones (required,minLength,pattern, etc.). Usae.preventDefault()para suprimir el tooltip del navegador y mostrar una interfaz personalizada.onResetse dispara cuando se hace clic en un botóntype="reset". NO restablece el estado de React -- debes manejar el restablecimiento de estado por ti mismo.
Variaciones
Inputs controlados vs no controlados:
// Controlado: el estado de React es la fuente de verdad
function Controlled() {
const [value, setValue] = useState("");
return <input value={value} onChange={(e) => setValue(e.target.value)} />;
}
// No controlado: el DOM es la fuente de verdad
function Uncontrolled() {
const inputRef = useRef<HTMLInputElement>(null);
const handleSubmit = () => {
console.log(inputRef.current?.value);
};
return <input ref={inputRef} defaultValue="" />;
}Extracción de FormData (patrón moderno -- sin necesidad de estado controlado):
function FormDataExample() {
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
// Obtén campos individuales
const email = formData.get("email") as string;
// Convierte a objeto plano
const data = Object.fromEntries(formData);
// Maneja múltiples valores (p.ej., multi-select, casillas de verificación con el mismo nombre)
const tags = formData.getAll("tags") as string[];
console.log({ email, data, tags });
};
return (
<form onSubmit={handleSubmit}>
<input name="email" type="email" />
<select name="tags" multiple>
<option value="react">React</option>
<option value="typescript">TypeScript</option>
</select>
<button type="submit">Enviar</button>
</form>
);
}Acciones de formulario de React 19 con useActionState:
"use client";
import { useActionState } from "react";
type State = { message: string; errors?: Record<string, string> };
async function submitSignup(
prevState: State,
formData: FormData
): Promise<State> {
const email = formData.get("email") as string;
const password = formData.get("password") as string;
if (!email.includes("@")) {
return { message: "", errors: { email: "Correo no válido" } };
}
if (password.length < 8) {
return { message: "", errors: { password: "Muy corto" } };
}
// Simula una llamada a la API
await new Promise((r) => setTimeout(r, 1000));
return { message: `¡Bienvenida, ${email}!` };
}
export default function ActionForm() {
const [state, formAction, isPending] = useActionState(submitSignup, {
message: "",
});
return (
<form action={formAction}>
<input name="email" type="email" placeholder="Correo" />
{state.errors?.email && <span>{state.errors.email}</span>}
<input name="password" type="password" placeholder="Contraseña" />
{state.errors?.password && <span>{state.errors.password}</span>}
<button type="submit" disabled={isPending}>
{isPending ? "Registrando..." : "Registrarse"}
</button>
{state.message && <p>{state.message}</p>}
</form>
);
}onChange de entrada de archivo:
function FileUpload() {
const [fileName, setFileName] = useState<string>("");
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setFileName(file.name);
console.log("Tamaño:", file.size, "Tipo:", file.type);
}
};
return (
<div>
<input type="file" onChange={handleChange} accept="image/*" />
{fileName && <p>Seleccionado: {fileName}</p>}
</div>
);
}Select onChange con valores tipificados:
type Color = "red" | "green" | "blue";
function ColorPicker() {
const [color, setColor] = useState<Color>("red");
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setColor(e.target.value as Color);
};
return (
<select value={color} onChange={handleChange}>
<option value="red">Rojo</option>
<option value="green">Verde</option>
<option value="blue">Azul</option>
</select>
);
}Notas de TypeScript
// React.ChangeEvent<T> -- para manejadores onChange
// T debe coincidir con el elemento: HTMLInputElement, HTMLSelectElement, HTMLTextAreaElement
const handleInput = (e: React.ChangeEvent<HTMLInputElement>) => {
e.target.value; // string -- el valor actual del input
e.target.name; // string -- el atributo name
e.target.type; // string -- "text", "checkbox", "email", etc.
e.target.checked; // boolean -- solo significativo para casillas de verificación/radios
};
// React.FormEvent<HTMLFormElement> -- para onSubmit / onReset
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
e.currentTarget; // HTMLFormElement
new FormData(e.currentTarget); // FormData del formulario
};
// Tipificación de valores de FormData
const formData = new FormData(form);
const email = formData.get("email"); // FormDataEntryValue | null
const emailStr = formData.get("email") as string; // afirma a string
const file = formData.get("avatar") as File; // afirma a File
// Manejador de unión para múltiples tipos de input
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>
) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
// Tipificación de React 19 useActionState
const [state, action, isPending] = useActionState<State, FormData>(
submitAction,
initialState
);
// Tipificación de un formulario con Record para campos dinámicos
type FormState = Record<string, string | boolean>;Trampas
-
El
onChangede React NO es el eventochangedel DOM -- En HTML nativo,changese dispara en blur para inputs de texto. ElonChangede React se dispara en cada pulsación de tecla, comportándose como el evento nativoinput. Esto sorprende a los desarrolladores que vienen de JS vanilla. Si necesitas comportamiento solo en blur, usaonBluren su lugar. -
onResetno restablece el estado de React -- Hacer clic en un botóntype="reset"restablece los valores del formulario DOM a sudefaultValuepero NO actualiza el estado de React. Si usas inputs controlados, los valores de estado inmediatamente sobrescriben el restablecimiento del DOM. Solución: ManejaonResetexplícitamente y restablece tu estado a valores iniciales. -
onChangede casilla de verificación te dae.target.checked, noe.target.value-- Para casillas de verificación,e.target.valuees siempre el atributovalueestático (por defecto"on"). El estado de alternancia real está ene.target.checked. Solución: Verificae.target.type === "checkbox"y lee.checkedpara estado booleano. -
FormData.get()devuelveFormDataEntryValue | null-- El tipo de retorno esstring | File | null, no solostring. Si lo pasas directamente a una función que esperastring, TypeScript lanzará un error. Solución: Afirma el tipo:formData.get("email") as string. -
e.currentTargetesnulldespués de operaciones asincrónicas -- Al igual que otros Eventos Sintéticos, acceder ae.currentTargetdentro de unawaitosetTimeoutdanull. Solución: Capturaconst form = e.currentTarget;antes de cualquier trabajo asincrónico, luego usanew FormData(form). -
Los inputs de archivo no pueden ser controlados -- Establecer
valueen un input de archivo no está permitido por razones de seguridad. Los inputs de archivo son siempre no controlados. UsaonChangepara leer el archivo seleccionado y almacenarlo en state, pero no intentes establecer el valor del input. -
El
useActionStatede React 19 requiere un modelo mental diferente -- La función de acción recibe(prevState, formData)y devuelve el nuevo estado. No haye.preventDefault()-- el formulario usa la propiedadactionen lugar deonSubmit. MezclaronSubmityactionen el mismo formulario lleva a un comportamiento confuso. Solución: Elige un patrón por formulario: ya seaonSubmitconpreventDefault, oactionconuseActionState.
Alternativas
| Alternativa | Úsalo cuando | No lo uses cuando |
|---|---|---|
Propiedad action de React 19 + useActionState | Formularios validados por servidor, mejora progresiva, estados pendientes | Necesitas control granular en el cliente de cada pulsación de tecla |
| React Hook Form | Formularios complejos con muchos campos, validación profunda, sensibles al rendimiento | Formularios simples con 1-3 campos |
| Zod + react-hook-form | Validación basada en esquema compartida entre cliente y servidor | La validación es trivial (solo required) |
| Formik | Proyectos heredados que ya lo usan | Nuevos proyectos (prefiere React Hook Form o nativa) |
Inputs no controlados + FormData | Formularios simples donde solo necesitas valores al enviar | Necesitas validación en tiempo real o estado derivado de inputs |
| Acciones de Servidor (Next.js) | Envío de formulario que ejecuta lógica en el lado del servidor directamente | Aplicaciones solo cliente sin servidor |
Preguntas Frecuentes
¿Cómo se diferencia el onChange de React del evento change nativo del DOM?
El onChange de React se dispara en cada pulsación de tecla para inputs de texto, comportándose como el evento nativo input. El evento change del DOM nativo solo se dispara en blur. Esta es una fuente común de confusión para desarrolladores que vienen de JS vanilla.
¿Cuál es la diferencia entre inputs controlados y no controlados en React?
- Controlado: El estado de React es la fuente de verdad. Estableces
valuey lo actualizas a través deonChange. - No controlado: El DOM es la fuente de verdad. Usas
defaultValuey lees a través de unref. - Usa controlado para validación en tiempo real o estado derivado; no controlado para formularios simples solo en envío.
¿Cómo extraes datos de formulario sin usar estado controlado?
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const data = Object.fromEntries(new FormData(e.currentTarget));
console.log(data); // { email: "...", password: "..." }
};¿Por qué debes llamar a e.preventDefault() en onSubmit?
Sin e.preventDefault(), el navegador realiza su comportamiento predeterminado de envío de formulario, que causa una recarga de página completa. Para manejo en el cliente en React, siempre necesitas prevenir esto.
Trampa: ¿Por qué hacer clic en un botón de restablecimiento no restablece los valores de mi input controlado?
El botón type="reset" restablece los valores del formulario DOM a su defaultValue, pero NO actualiza el estado de React. Dado que los inputs controlados inmediatamente sobrescriben el DOM con valores de estado, el restablecimiento parece que no hace nada. Maneja onReset explícitamente y restablece tu estado a valores iniciales.
¿Cómo manejas casillas de verificación en un único manejador onChange junto con inputs de texto?
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, type } = e.target;
const value = type === "checkbox"
? e.target.checked
: e.target.value;
setFormData((prev) => ({ ...prev, [name]: value }));
};Verifica e.target.type === "checkbox" y lee .checked en lugar de .value.
¿Qué es useActionState en React 19 y cómo se diferencia de onSubmit?
useActionStateusa la propiedadactionen<form>en lugar deonSubmit- La función de acción recibe
(prevState, formData)y devuelve el nuevo estado - Proporciona un booleano
isPendingpara estados de carga - No mezcles
onSubmityactionen el mismo formulario
Trampa: ¿Por qué e.currentTarget es null después de un await dentro de mi manejador de envío?
Los eventos sintéticos de React se reciclan después de que el manejador devuelve. Acceder a e.currentTarget dentro de un await o setTimeout da null. Captura primero: const form = e.currentTarget; luego usa new FormData(form).
¿Cómo usas onInvalid para mostrar mensajes de validación personalizados en lugar de tooltips del navegador?
<input
required
onInvalid={(e) => {
e.preventDefault(); // suprime tooltip del navegador
setError(e.currentTarget.validationMessage);
}}
/>¿Por qué no puedes establecer el valor de un input de archivo en React?
Los inputs de archivo no pueden ser controlados por razones de seguridad. El navegador prohíbe establecer value en <input type="file">. Son siempre no controlados. Usa onChange para leer el archivo seleccionado y almacenarlo en state.
¿Cuál es el tipo TypeScript correcto para FormData.get() y por qué necesita una afirmación de tipo?
FormData.get() devuelve FormDataEntryValue | null, que es string | File | null. Si lo pasas a una función que espera string, TypeScript lanzará un error. Afirma el tipo: formData.get("email") as string.
¿Cómo tipificas un único manejador onChange que funciona con elementos input, select y textarea?
const handleChange = (
e: React.ChangeEvent<
HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
>
) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};Relacionado
- Eventos de Ratón -- Manejo de clics, flotamientos y movimiento del ratón
- Eventos de Teclado -- Responder a pulsaciones de teclas y atajos de teclado