Inicio / Artículos

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.

Editor: REACTAPP.TOP13 min de lectura2992 palabras

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:

  1. Campos obligatorios
  2. Formato de correo electrónico
  3. L longitud mínima de la contraseña
  4. 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:

  1. ¿Está el correo electrónico ya registrado?
  2. ¿Está el usuario autorizado?
  3. ¿Es válido el precio?
  4. ¿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:

  1. Mantenga el estado local en lugar de global sin motivo.
  2. Divida el formulario en secciones para que cada cambio genere menos actualizaciones:
Form
├── PersonalDetails
├── Address
├── Education
├── Experience
└── Documents
  1. Utilice una biblioteca adecuada, como React Hook Form.
  2. Evite cálculos intensivos o llamadas a API por cada carácter.
  3. 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]: value cubre 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 elemento select, 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

  • Un Formulario de Fecha de Nacimiento Validada en Next.js con Inputs Controlados y Callbacks — Crea un pequeño formulario cliente en Next.js que haga seguimiento de los ingresos con useState, rechace fechas de nacimiento inválidas, muestre errores accesibles y entregue datos limpios al componente padre.