En el artículo anterior aprendimos a controlar un input individual. Pero en el mundo real, los formularios normalmente no tiene un único campo.
Un formulario de registro típico tiene nombre, email, contraseña, confirmación de contraseña, dirección, aceptación de términos… y muy importante necesitamos reglas de validación.
Por ejemplo,
- El email debe tener formato de email.
- La contraseña debe tener más de 6 caracteres.
- Las contraseñas deben coincidir.
- No puedes enviar el formulario si hay errores.
Si intentamos hacer esto creando una variable useState para cada campo y otra variable useState para cada error, tu componente se va convertir básicamente en un infierno.
Hoy vamos a ver cómo abordar esto sin volvernos locos en el intento.
Estrategia de estado único
En lugar de tener nombre, setNombre, email, setEmail… vamos a agrupar todo el formulario en un único objeto de estado. Esto ya lo adelantamos en el artículo anterior, pero ahora es obligatorio.
Y no solo eso, ahora necesitamos un segundo estado para los errores.
const [formData, setFormData] = useState({
username: '',
email: '',
password: ''
});
const [errors, setErrors] = useState({});
El objeto errors tendrá la misma estructura de claves que formData, pero sus valores serán los mensajes de error (ej: { email: "Email inválido" }).
Si una clave no existe o está vacía, asumimos que ese campo es válido.
La función validate
Vamos a separar la lógica de validación de la lógica de renderizado. Crearemos una función que reciba los datos y devuelva los errores encontrados.
const validate = (values) => {
const errors = {};
const regexEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!values.username) {
errors.username = "El nombre de usuario es obligatorio";
}
if (!values.email) {
errors.email = "El email es obligatorio";
} else if (!regexEmail.test(values.email)) {
errors.email = "El formato del email no es válido";
}
if (!values.password) {
errors.password = "La contraseña es obligatoria";
} else if (values.password.length < 6) {
errors.password = "La contraseña debe tener al menos 6 caracteres";
}
return errors;
};
Esta función es pura (no modifica el estado, solo calcula y devuelve). Esto hace que sea muy fácil de testear y reutilizar.
El manejo del Envío (onSubmit)
Aquí es donde orquestamos todo. Cuando el usuario intenta enviar:
Prevenimos la recarga (preventDefault).
Ejecutamos la validación.
Si hay errores ➡ Actualizamos el estado errors y detenemos el envío.
Si no hay errores ➡ Enviamos los datos a la API.
const handleSubmit = (e) => {
e.preventDefault();
// 1. Validamos los datos actuales
const validationErrors = validate(formData);
// 2. Actualizamos el estado de errores visuales
setErrors(validationErrors);
// 3. Comprobamos si el objeto de errores está vacío
if (Object.keys(validationErrors).length === 0) {
console.log("Formulario válido. Enviando datos...", formData);
// Aquí iría la llamada a la API
// enviarDatos(formData);
} else {
console.log("Formulario inválido, corrige los errores");
}
};
Feedback visual (UX)
De nada sirve validar internamente si no le decimos al usuario lo que está haciendo mal, a través de algunos mensajes.
En nuestro JSX, vamos a renderizar condicionalmente un mensaje de error debajo de cada input solo si existe un error asociado a ese campo.
<div>
<label>Email</label>
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
// Añadimos una clase CSS condicional para poner el borde rojo
className={errors.email ? 'input-error' : ''}
/>
{/* Renderizado condicional del mensaje */}
{errors.email && <span className="error-message">{errors.email}</span>}
</div>
El problema del “grito Inmediato”
Si implementamos validación en tiempo real (en el onChange), tenemos un problema de Experiencia de Usuario (UX).
Si valido que el email debe tener una arroba @, en cuanto el usuario escriba la primera letra “L”, le saltará un error rojo gigante: “EMAIL INVÁLIDO”. ¡Claro que es inválido, no he terminado de escribir! Eso es muy agresivo y muy molesto.
Para solucionar esto, introducimos un tercer estado: Touched.
const [touched, setTouched] = useState({});
const handleBlur = (e) => {
// Cuando el usuario sale del input (pierde foco)
setTouched({
...touched,
[e.target.name]: true
});
// Opcional: Validar aquí también
};
Ahora, solo mostramos el error si el campo tiene un error y ha sido touched (o si hemos intentado hacer submit).
{errors.email && touched.email && <span className="error">{errors.email}</span>}
Usaremos el evento onBlur en los inputs para marcar el campo como “touched”.
Nota sobre Librerías (React Hook Form)
Lo que acabamos de hacer es la forma “manual”. Es interesante verla para entender cómo funcionan las cosas.
Sin embargo, para formularios muy grandes, gestionar todo este estado manualmente afecta al rendimiento (cada letra renderiza todo el formulario).
En la vida real, lo normal es usar una librería especializadas. La reina indiscutible hoy en día es React Hook Form.
Esta librería optimiza el rendimiento evitando re-renderizados innecesarios y nos ofrece una API muy limpia para validar. Pero no os recomiendo saltar a ella hasta que no dominéis la gestión manual que acabamos de ver.
