En esta página

Formularios Controlados

14 min lectura TextoCap. 4 — Datos y formularios

Formularios controlados vs no controlados

En React existen dos enfoques para manejar formularios:

Controlados: React controla el valor del campo en cada momento mediante estado. El elemento es la fuente de verdad en React.

function Controlado() {
  const [valor, setValor] = useState('');
  // React siempre sabe el valor actual
  return <input value={valor} onChange={(e) => setValor(e.target.value)} />;
}

No controlados: El DOM gestiona el valor. React accede al valor solo cuando es necesario (en el submit).

function NoControlado() {
  const inputRef = useRef<HTMLInputElement>(null);

  const manejarSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    console.log(inputRef.current?.value); // Solo al enviar
  };

  return <form onSubmit={manejarSubmit}><input ref={inputRef} /></form>;
}

Cuándo usar cada uno:

Característica Controlado No controlado
Validación en tiempo real
Valores derivados
Rendimiento (muchos campos) Peor Mejor
Simplicidad de código Media Alta
Integración con librerías externas Difícil Fácil

Patrón de formulario con objeto de estado

Para formularios con múltiples campos, un único objeto de estado es más manejable:

interface DatosFormulario {
  nombre: string;
  apellido: string;
  email: string;
  telefono: string;
  rol: string;
}

function FormularioPerfil(): React.JSX.Element {
  const [form, setForm] = useState<DatosFormulario>({
    nombre: '', apellido: '', email: '', telefono: '', rol: 'usuario',
  });

  // Handler genérico que funciona con cualquier campo
  const actualizar = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
    const { name, value } = e.target;
    setForm((prev) => ({ ...prev, [name]: value }));
  };

  return (
    <form>
      <input name="nombre" value={form.nombre} onChange={actualizar} />
      <input name="apellido" value={form.apellido} onChange={actualizar} />
      <input name="email" type="email" value={form.email} onChange={actualizar} />
      <select name="rol" value={form.rol} onChange={actualizar}>
        <option value="usuario">Usuario</option>
        <option value="editor">Editor</option>
        <option value="admin">Administrador</option>
      </select>
    </form>
  );
}

Validación avanzada con esquemas

Para proyectos más grandes, valida con Zod:

import { z } from 'zod';

const esquemaRegistro = z.object({
  nombre: z.string().min(2, 'Mínimo 2 caracteres').max(50),
  email: z.string().email('Email inválido'),
  password: z.string().min(8, 'Mínimo 8 caracteres').regex(
    /[A-Z]/, 'Debe contener al menos una mayúscula'
  ),
  edad: z.number().int().min(13, 'Debes tener al menos 13 años'),
});

type DatosRegistro = z.infer<typeof esquemaRegistro>;

function validarConZod(datos: unknown): { valido: boolean; errores: Record<string, string> } {
  const resultado = esquemaRegistro.safeParse(datos);
  if (resultado.success) return { valido: true, errores: {} };

  const errores: Record<string, string> = {};
  resultado.error.issues.forEach((issue) => {
    const campo = issue.path[0] as string;
    errores[campo] = issue.message;
  });

  return { valido: false, errores };
}

React 19 useActionState — formularios del futuro

useActionState es el hook de React 19 para manejar el estado de formularios con actions asíncronas:

import { useActionState } from 'react';

interface Estado {
  campo?: string;
  error?: string;
}

async function accionBusqueda(_prev: Estado, formData: FormData): Promise<Estado> {
  const termino = formData.get('termino') as string;
  if (!termino.trim()) return { error: 'Ingresa un término de búsqueda' };

  const res = await fetch(`/api/buscar?q=${encodeURIComponent(termino)}`);
  if (!res.ok) return { error: 'Error en la búsqueda' };

  return { campo: termino };
}

function BuscadorConAction(): React.JSX.Element {
  const [estado, accion, pending] = useActionState(accionBusqueda, {});

  return (
    <form action={accion}>
      <input name="termino" type="search" placeholder="Buscar…" />
      <button type="submit" disabled={pending}>
        {pending ? 'Buscando…' : 'Buscar'}
      </button>
      {estado.error && <p role="alert">{estado.error}</p>}
      {estado.campo && <p>Resultados para: {estado.campo}</p>}
    </form>
  );
}

useFormStatus — estado del formulario padre

useFormStatus (del paquete react-dom) permite que un componente hijo conozca el estado de envío del formulario más cercano:

import { useFormStatus } from 'react-dom';

function CampoConCarga({ label, name }: { label: string; name: string }) {
  const { pending } = useFormStatus();

  return (
    <div>
      <label htmlFor={name}>{label}</label>
      <input
        id={name}
        name={name}
        disabled={pending}
        aria-busy={pending}
      />
    </div>
  );
}

function FormularioCompleto(): React.JSX.Element {
  const [estado, accion] = useActionState(miAccion, {});

  return (
    <form action={accion}>
      {/* useFormStatus funciona en componentes hijos del form */}
      <CampoConCarga label="Email" name="email" />
      <CampoConCarga label="Contraseña" name="password" />
      <BotonSubmit />
    </form>
  );
}

Accesibilidad en formularios

Los formularios accesibles deben cumplir con WCAG AA:

function CampoAccesible({
  id, label, tipo = 'text', error, requerido = false,
}: {
  id: string; label: string; tipo?: string; error?: string; requerido?: boolean;
}) {
  const errorId = `${id}-error`;

  return (
    <div className="campo">
      <label htmlFor={id}>
        {label}
        {requerido && <span aria-hidden="true"> *</span>}
        {requerido && <span className="sr-only"> (requerido)</span>}
      </label>
      <input
        id={id}
        type={tipo}
        required={requerido}
        aria-invalid={error ? 'true' : 'false'}
        aria-describedby={error ? errorId : undefined}
        aria-required={requerido}
      />
      {error && (
        <span id={errorId} role="alert" className="error">
          {error}
        </span>
      )}
    </div>
  );
}

Los atributos ARIA garantizan que los lectores de pantalla anuncien correctamente los errores y el estado de los campos, cumpliendo los criterios WCAG 3.3.1 (Error Identification) y 1.3.1 (Info and Relationships).

useActionState reemplaza a useFormState de React 18
React 19 renombró useFormState a useActionState y lo movió del paquete react-dom a react. Si ves código con useFormState, es la API antigua de React 18. useActionState funciona con formularios HTML nativos usando el atributo action={fn} en lugar de onSubmit.
Controlado vs no controlado — cuándo usar cada uno
Los formularios controlados (con useState) son los más comunes y deben ser tu primera opción: tienes control total del valor en cada keystroke. Los no controlados (con useRef) son útiles para integraciones con librerías externas o cuando el formulario tiene muchos campos y el rendimiento es crítico.
Siempre usa noValidate con validación personalizada
Cuando implementas tu propia validación, agrega noValidate al elemento form para desactivar la validación nativa del navegador que puede interferir con tu UI. Asegúrate de compensar con ARIA attributes (aria-invalid, aria-describedby) para mantener la accesibilidad.