Formularios React hechos a mano: entradas controladas, validación y estados de envío
Aprenda cómo funcionan los formularios de React, desde las entradas controladas y los manejadores por tipo hasta la validación, los estados de envío, los campos dinámicos, las subidas y cuándo resulta útil una biblioteca de formularios.
Fuentes: React Forms: A Complete Guide from Basics to Production Patterns
Iniciar sesión, realizar pagos, buscar, configurar el perfil, encuestas: casi todas las aplicaciones se basan en formularios. En React, un formulario implica gestionar valores, validaciones, mensajes de error, envíos, estados de carga y errores del servidor, campos que se añaden en tiempo de ejecución, opciones de reinicio y aspectos relacionados con el rendimiento. Esta guía explica todo esto paso a paso, desde los inputs controlados hasta un formulario de registro completo sin necesidad de bibliotecas, y muestra cuándo vale la pena utilizar React Hook Form y Zod.
Cómo funciona un formulario en una aplicación React
Un formulario recopila información del usuario, como en esta pantalla de registro:
┌──────────────────────────────┐
│ Create Account │
│ │
│ Name [______________] │
│ Email [______________] │
│ Password [______________] │
│ │
│ [ Register ] │
└──────────────────────────────┘
React conecta esos inputs con la lógica de la aplicación, y casi todos los formularios siguen el mismo ciclo de vida:
User Input
↓
Form State
↓
Validation
↓
Submit
↓
API Request
↓
Success / Error
Componentes controlados: el estado como fuente de verdad
En un componente controlado, el value de la entrada proviene del estado de React y cada cambio se escribe nuevamente a través de onChange.
import { useState } from "react";
function LoginForm() {
const [email, setEmail] = useState("");
return (
<input
value={email}
onChange={(event) => {
setEmail(event.target.value);
}}
/>
);
}
Al establecer value={email}, React decide qué se muestra en el campo. Cada tecla presionada pasa por este bucle:
User types
↓
onChange fires
↓
setEmail()
↓
React state updates
↓
Component renders
↓
Input receives new value
Dado que el valor mostrado siempre se deriva del estado, este es la única fuente de verdad.
El nombre “controlado” refleja que la entrada no posee su propio valor:
<input
value={email}
onChange={handleChange}
/>
Los datos circulan desde el estado hasta el elemento, a través de las teclas introducidas por el usuario y del manejador, y de vuelta al estado:
React State
│
▼
input value
│
▼
User types
│
▼
onChange
│
▼
React State
Un objeto de estado para muchos campos
Llamar a useState por cada campo genera un escalamiento deficiente:
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
Es más fácil mantener un único objeto cuya clave es el nombre del campo. Este formato almacena tres campos en un solo objeto, comparte una sola función handleChange y registra los datos al enviar el formulario.
import { useState } from "react";
function RegisterForm() {
const [form, setForm] = useState({
name: "",
email: "",
password: "",
});
const handleChange = (event) => {
const { name, value } = event.target;
setForm((previous) => ({
...previous,
[name]: value,
}));
};
const handleSubmit = (event) => {
event.preventDefault();
console.log(form);
};
return (
<form onSubmit={handleSubmit}>
<input
name="name"
value={form.name}
onChange={handleChange}
/>
<input
name="email"
value={form.email}
onChange={handleChange}
/>
<input
name="password"
type="password"
value={form.password}
onChange={handleChange}
/>
<button type="submit">
Register
</button>
</form>
);
}
La línea clave es la propiedad calculada [name]: value. El atributo name de cada campo de entrada coincide con una clave de estado, por lo que un único manejador actualiza el campo que generó la acción.
En el caso de los campos de texto, el manejador lee el contenido del elemento desde el evento:
<input
value={name}
onChange={(event) => {
setName(event.target.value);
}}
/>
event.target corresponde al campo de entrada, por lo que event.target.value contiene su texto actual:
User types: Salim
event.target.value
↓
"Salim"
Manejo correcto de cada tipo de campo
Los formularios combinan controles de texto, contraseña, número, casilla de verificación, radio, selección, área de texto, archivo, fecha y rango. La mayoría siguen el mismo patrón, con algunas diferencias importantes.
Texto, contraseña y número
Un campo de texto vincula value a su clave:
<input
type="text"
value={form.name}
onChange={handleChange}
/>
Un campo de contraseña difiere solo en type:
<input
type="password"
value={form.password}
onChange={handleChange}
/>
Igual ocurre con un campo numérico:
<input
type="number"
value={form.age}
onChange={handleChange}
/>
El problema es que event.target.value es una cadena, incluso cuando type="number". El fragmento a continuación mezcla código y explicación, pero lo importante es que se obtiene "25" en lugar de 25, y se necesita Number() para realizar la conversión.
const age = event.target.value;
may produce:
"25" rather than: 25
If you need a number:
const age = Number(event.target.value);
Tenga en cuenta los valores vacíos: Number("") devuelve 0, por lo que muchos formularios conservan la cadena original y solo la convierten durante la validación o al enviar el formulario.
Los checkboxes utilizan checked
Para un checkbox, vincule checked a un valor booleano y lea event.target.checked:
<input
type="checkbox"
checked={form.agree}
onChange={(event) => {
setForm((previous) => ({
...previous,
agree: event.target.checked,
}));
}}
/>
event.target.value en un checkbox devuelve su atributo value (por defecto "on"), no si está marcado.
Grupos de select, textarea y radio
Un select se controla a través del atributo value del elemento select; una primera opción vacía funciona como marcador de posición.
<select
name="role"
value={form.role}
onChange={handleChange}
>
<option value="">
Select role
</option>
<option value="frontend">
Frontend Developer
</option>
<option value="backend">
Backend Developer
</option>
</select>
React trata al textarea como un input, con una propiedad value en lugar de texto hijo:
<textarea
name="message"
value={form.message}
onChange={handleChange}
/>
Los botones de radio en un grupo comparten un name. Cada uno se marca cuando su value coincide con el estado, y el manejador compartido almacena la elección en form.gender.
<label>
<input
type="radio"
name="gender"
value="male"
checked={form.gender === "male"}
onChange={handleChange}
/>
Male
</label>
<label>
<input
type="radio"
name="gender"
value="female"
checked={form.gender === "female"}
onChange={handleChange}
/>
Female
</label>
Tomando el control de la envío
Por defecto, un navegador envía el formulario por sí mismo y puede navegar o volver a cargar la página. Las aplicaciones React manejan el envío mediante JavaScript, por lo que el manejador de envío comienza con event.preventDefault(), como en el ejemplo de registro. A continuación, el flujo lo controlas tú:
Submit
↓
preventDefault()
↓
Validate
↓
API
↓
Update UI
Validación antes de la solicitud
La validación verifica que los datos ingresados sean aceptables. Una primera versión recopila los errores según el campo:
const validate = () => {
const errors = {};
if (!form.name.trim()) {
errors.name = "Name is required";
}
if (!form.email.trim()) {
errors.email = "Email is required";
}
if (!form.password) {
errors.password = "Password is required";
}
return errors;
};
El manejador de envío valida primero, almacena los errores y sale temprano si hay alguno:
const handleSubmit = (event) => {
event.preventDefault();
const validationErrors = validate();
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
// Submit form
};
Los errores se encuentran en su propio estado:
const [errors, setErrors] = useState({});
En un momento dado, pueden contener:
{
email: "Invalid email",
password: "Password must contain at least 8 characters"
}
Cada campo muestra su mensaje de forma condicional:
{errors.email && (
<p>{errors.email}</p>
)}
Una versión mejorada de validate toma el formulario como argumento, lo que lo hace puro y susceptible de pruebas, y verifica el formato y la longitud una vez que hay un campo presente:
function validate(form) {
const errors = {};
if (!form.name.trim()) {
errors.name = "Name is required";
}
if (!form.email.trim()) {
errors.email = "Email is required";
} else if (!form.email.includes("@")) {
errors.email = "Invalid email";
}
if (!form.password) {
errors.password = "Password is required";
} else if (form.password.length < 8) {
errors.password =
"Password must contain at least 8 characters";
}
return errors;
}
El manejador lo llama de la misma manera:
const errors = validate(form);
if (Object.keys(errors).length > 0) {
setErrors(errors);
return;
}
La prueba includes("@") solo detecta errores tipográficos obvios; la verdadera prueba de que una dirección funciona es un correo de confirmación.
La validación del cliente y del servidor tienen funciones diferentes
La validación en el lado del cliente se ejecuta en el navegador y generalmente abarca:
- Campos obligatorios
- Formato de correo electrónico
- L longitud mínima de la contraseña
- Confirmación de la contraseña
Ofrece una respuesta rápida, una mejor experiencia y menos solicitudes innecesarias, pero no constituye una barrera de seguridad. El servidor debe repetir las verificaciones y responder a preguntas que el navegador no puede resolver:
- ¿Está el correo electrónico ya registrado?
- ¿Está el usuario autorizado?
- ¿Es válido el precio?
- ¿Puede este usuario realizar esta operación?
Las aplicaciones seguras utilizan ambos métodos. Nunca escriba lógica como esta:
if (clientValidationPassed) {
// Data is safe
}
Se pueden eludir las verificaciones del navegador editando la página o llamando directamente a la API, por lo que el backend valida cada solicitud de forma independiente:
Request
↓
Authentication
↓
Authorization
↓
Validation
↓
Business Logic
↓
Database
Modelado de los estados de envío
Un formulario en producción pasa por estados distintos que la interfaz de usuario debe reflejar:
Idle
↓
Submitting
↓
Success
or
Idle
↓
Submitting
↓
Error
Hay dos elementos de estado que los registran:
const [isSubmitting, setIsSubmitting] = useState(false);
const [serverError, setServerError] = useState("");
El manejador asíncrono activa la bandera, elimina los errores antiguos del servidor, espera la solicitud, registra un mensaje amigable en caso de fallo y restablece la bandera en finally en cualquier situación.
const handleSubmit = async (event) => {
event.preventDefault();
setIsSubmitting(true);
setServerError("");
try {
await createUser(form);
} catch (error) {
setServerError("Unable to create account");
} finally {
setIsSubmitting(false);
}
};
Deshabilitar y volver a etiquetar el botón durante la solicitud evita envíos duplicados:
<button
type="submit"
disabled={isSubmitting}
>
{isSubmitting
? "Creating..."
: "Create Account"}
</button>
El ciclo de vida del botón sigue el mismo patrón que la solicitud:
User clicks Submit
↓
Request starts
↓
Button disabled
↓
Request finishes
↓
Button enabled
Entradas controladas versus no controladas
Con una entrada controlada, el estado de React almacena el valor. El bloque a continuación muestra la forma abreviada, un componente completo y el flujo de datos, separados por líneas de asteriscos.
<input
value={email}
onChange={handleChange}
/>
********************************************
function Form() {
const [email, setEmail] = useState("");
return (
<input
value={email}
onChange={(event) =>
setEmail(event.target.value)
}
/>
);
}
********************************************
DOM event
↓
React state
↓
Render
↓
DOM value
Con una entrada no controlada, el DOM mantiene el valor y React lo lee a través de un ref, generalmente al enviar el formulario, sin almacenar cada tecla pulsada:
const emailRef = useRef();
<input ref={emailRef} />
********************************************
function Form() {
const emailRef = useRef(null);
const handleSubmit = (event) => {
event.preventDefault();
console.log(emailRef.current.value);
};
return (
<form onSubmit={handleSubmit}>
<input ref={emailRef} />
<button type="submit">
Submit
</button>
</form>
);
}
********************************************
DOM
↓
Input value
Elija entradas controladas para la validación inmediata, interfaces de usuario condicionales, valores derivados, formato, estado controlado por React o interacciones personalizadas. Dado que React conoce el valor, la interfaz de usuario puede reaccionar al instante:
{password.length < 8 && (
<p>Password is too short</p>
)}
Elija entradas no controladas para formularios simples, cuando el estado por tecla añade poco valor, cuando desea menos actualizaciones, o cuando una biblioteca gestiona los referencias por usted. Las acciones de formulario en React 19 también se basan en este modelo; consulte formularios asincrónicos con useActionState y useOptimistic.
Cuando una biblioteca de formularios merece su lugar
React Hook Form
Vincular docenas de campos a mano es tedioso. El método register de React Hook Form conecta un campo de entrada y declara sus reglas, handleSubmit valida los datos antes de llamar a la función de callback, y formState.errors muestra los mensajes de error.
import { useForm } from "react-hook-form";
function LoginForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm();
const onSubmit = (data) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
{...register("email", {
required: "Email is required",
})}
/>
{errors.email && (
<p>{errors.email.message}</p>
)}
<input
type="password"
{...register("password", {
required: "Password is required",
minLength: {
value: 8,
message:
"Password must be at least 8 characters",
},
})}
/>
{errors.password && (
<p>{errors.password.message}</p>
)}
<button type="submit">
Login
</button>
</form>
);
}
Esto simplifica el registro, la validación, los errores, el envío, los reinicios, los campos dinámicos y el estado del formulario. Dado que registra los elementos de entrada no controlados por defecto, evita volver a renderizar todo el formulario con cada tecla pulsada, como lo haría un formulario controlado de forma ingenua.
Validación de esquemas con Zod
A medida que aumentan las reglas, las instrucciones if dispersas se vuelven difíciles de gestionar. Zod describe la estructura esperada de los datos en una sola declaración:
import { z } from "zod";
const userSchema = z.object({
name: z
.string()
.min(2, "Name must be at least 2 characters"),
email: z
.string()
.email("Invalid email"),
password: z
.string()
.min(8, "Password must be at least 8 characters"),
});
Una combinación común en entornos de producción consiste en utilizar la biblioteca para manejar el estado y el esquema para definir las reglas:
React Hook Form
+
Zod
↓
Form state + validation
El esquema puede ser tan compacto:
const formSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
React Hook Form permite integrar un esquema a través de un resolvedor, lo cual es útil para formularios grandes cuyas reglas deben mantenerse consistentes.
Campos dinámicos, condicionales y dependientes
Grupos repetitivos
A menudo, los usuarios añaden entradas según sea necesario, como registros educativos:
Education
Degree [________]
College [________]
[ + Add Education ]
Guárdelos como un array de objetos:
const [education, setEducation] = useState([
{
degree: "",
college: "",
},
]);
Al agregar una fila se agrega un objeto vacío:
setEducation((previous) => [
...previous,
{
degree: "",
college: "",
},
]);
El renderizado recorre el array:
{education.map((item, index) => (
<div key={index}>
<input
value={item.degree}
onChange={(event) => {
// update degree
}}
/>
<input
value={item.college}
onChange={(event) => {
// update college
}}
/>
</div>
))}
Un índice key solo es seguro mientras las filas nunca se eliminen o reordenen; de lo contrario, use identificadores estables, ya que el texto tipado podría asociarse a la fila incorrecta.
Campos condicionales
Algunos campos dependen de una elección anterior, como los detalles de la empresa para una cuenta empresarial:
Account Type
○ Individual
○ Business
If the user chooses:
Business
show:
Company Name
GST Number
Company Address
Una expresión condicional se encarga de ello:
{form.accountType === "business" && (
<>
<input
name="companyName"
value={form.companyName}
onChange={handleChange}
/>
<input
name="gstNumber"
value={form.gstNumber}
onChange={handleChange}
/>
</>
)}
Si el usuario vuelve atrás, elimine o excluya los valores ocultos para que no se envíen datos obsoletos.
Selecciones en cascada
El país, el estado y la ciudad forman una cadena:
Country
↓
State
↓
City
Un cambio en la parte superior reinicia todo lo que está debajo antes de obtener nuevas opciones:
Country changes
↓
Clear State
↓
Fetch states
↓
User selects State
↓
Fetch cities
Dado que las opciones provienen del servidor, TanStack Query funciona bien junto con la biblioteca de formularios para el caché y los estados de carga.
Subida de archivos
No se pueden controlar los campos de archivo; lea el archivo desde event.target.files:
<input
type="file"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) {
console.log(file);
}
}}
/>
Para subirlo, envíe un cuerpo de tipo FormData:
const formData = new FormData();
formData.append("name", form.name);
formData.append("file", file);
await fetch("/api/upload", {
method: "POST",
body: formData,
});
No establezca manualmente Content-Type: multipart/form-data con fetch y FormData; el navegador genera ese encabezado con el límite requerido.
Mantener rápidos los formularios grandes
Los formularios grandes y complejos sobrecargan a aquellos simples que se vuelven a renderizar con cada tecla pulsada:
User types
↓
setState
↓
Render
↓
Large form tree
Contramedidas útiles:
- Mantenga el estado local en lugar de global sin motivo.
- Divida el formulario en secciones para que cada cambio genere menos actualizaciones:
Form
├── PersonalDetails
├── Address
├── Education
├── Experience
└── Documents
- Utilice una biblioteca adecuada, como React Hook Form.
- Evite cálculos intensivos o llamadas a API por cada carácter.
- Aplicue debounce a lo que debe ejecutarse, como la verificación de disponibilidad del nombre de usuario:
Username input
↓
Wait 300ms
↓
Check availability
Un formulario completo sin bibliotecas
El ejemplo final combina una constante initialForm para reiniciar el formulario, estados separados para errores de campo, errores de envío y del servidor, una función validate, y un envío asíncrono que envía JSON, considera como error cualquier respuesta que no sea OK, reinicia el formulario al tener éxito y siempre elimina la marca de carga. Las etiquetas se vinculan a los campos mediante htmlFor y id.
import { useState } from "react";
const initialForm = {
name: "",
email: "",
password: "",
};
function RegisterForm() {
const [form, setForm] = useState(initialForm);
const [errors, setErrors] = useState({});
const [isSubmitting, setIsSubmitting] =
useState(false);
const [serverError, setServerError] =
useState("");
const handleChange = (event) => {
const { name, value } = event.target;
setForm((previous) => ({
...previous,
[name]: value,
}));
};
const validate = () => {
const errors = {};
if (!form.name.trim()) {
errors.name = "Name is required";
}
if (!form.email.includes("@")) {
errors.email = "Enter a valid email";
}
if (form.password.length < 8) {
errors.password =
"Password must be at least 8 characters";
}
return errors;
};
const handleSubmit = async (event) => {
event.preventDefault();
const validationErrors = validate();
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
setErrors({});
setServerError("");
setIsSubmitting(true);
try {
const response = await fetch("/api/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(form),
});
if (!response.ok) {
throw new Error("Registration failed");
}
setForm(initialForm);
} catch (error) {
setServerError(
"Unable to create your account."
);
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="name">
Name
</label>
<input
id="name"
name="name"
value={form.name}
onChange={handleChange}
/>
{errors.name && (
<p>{errors.name}</p>
)}
</div>
<div>
<label htmlFor="email">
Email
</label>
<input
id="email"
name="email"
type="email"
value={form.email}
onChange={handleChange}
/>
{errors.email && (
<p>{errors.email}</p>
)}
</div>
<div>
<label htmlFor="password">
Password
</label>
<input
id="password"
name="password"
type="password"
value={form.password}
onChange={handleChange}
/>
{errors.password && (
<p>{errors.password}</p>
)}
</div>
{serverError && (
<p>{serverError}</p>
)}
<button
type="submit"
disabled={isSubmitting}
>
{isSubmitting
? "Creating..."
: "Create Account"}
</button>
</form>
);
}
export default RegisterForm;
Para entornos de producción, también establezca aria-invalid en los campos inválidos y vincule los mensajes con aria-describedby.
El flujo completo de un vistazo
Todo lo anterior encaja en un único árbol de decisiones, desde la entrada hasta la validación, pasando por errores en los campos o llamadas a una API con ramas de éxito y error:
React Form
│
▼
User Input
│
▼
Form State
│
▼
Validation
/ \
Valid Invalid
│ │
▼ ▼
Submit Show Errors
│
▼
API Call
/ \
Success Error
│ │
▼ ▼
Reset/Form Show Server
Success UI Error
Puntos clave
- Las entradas controladas hacen que el estado sea la fuente de verdad; un objeto más un manejador
[name]: valuecubre la mayoría de los campos. - Los números llegan como cadenas, las casillas de verificación utilizan
checked, los selectores se controlan en el elementoselect, y las entradas de archivo siempre son no controladas. - La validación en el cliente sirve al usuario; solo la validación en el servidor protege los datos.
- El estado de envío del modelo se indica explícitamente y se desactiva el envío durante las solicitudes.
- Agregue React Hook Form y Zod cuando la cantidad de campos, las reglas compartidas o el costo de renderizado hagan que manejar el estado a mano sea problemático.
Lecturas relacionadas
- Formas React Gestionadas por Esquema: Renderizado y Validación a Partir de Esquemas JSON — Cómo renderizar formularios React validados directamente a partir de esquemas JSON, manejar $ref, oneOf y ramas if/then, integrar widgets personalizados y evitar trampas comunes de validación.
- Utilidades de auditoría en utils.js: Seis Elementos Incorporados que Sustituyen a los Helper Manuales — Conozca qué APIs nativas de JavaScript pueden reemplazar soluciones como clonaciones profundas, copias defensivas, tiempos de espera manuales, promesas diferidas y lodash groupBy, así como las consideraciones que deben tenerse en cuenta.