Skip to content

Formularios ​

Login, registro, contacto, checkout, filtros... los formularios están en todas partes. Aprenderás los inputs controlados, cómo manejar varios campos con un solo estado, validar y mostrar errores, y las nuevas Actions de React 19 (useActionState y useFormStatus).

Concepto ​

Hay dos formas de manejar inputs en React:

ControladoNo controlado
Quién guarda el valorEl estado de ReactEl DOM
Cómo se leeLa variable de estadoFormData o una ref al enviar
VentajasValidación en vivo, formatear mientras escribes, deshabilitar botonesMenos código, menos renders
Uso típicoFormularios interactivosFormularios simples, Actions de React 19

Un input controlado tiene value (o checked) conectado al estado y onChange que lo actualiza. React es "la única fuente de verdad".

jsx
const [email, setEmail] = useState('')
<input value={email} onChange={(e) => setEmail(e.target.value)} />

Todos los tipos de input ​

jsx
import { useState } from 'react'

export default function TiposDeInput() {
  const [texto, setTexto] = useState('')
  const [cantidad, setCantidad] = useState(1)
  const [mensaje, setMensaje] = useState('')
  const [pais, setPais] = useState('mx')
  const [acepta, setAcepta] = useState(false)
  const [envio, setEnvio] = useState('normal')
  const [intereses, setIntereses] = useState([])

  function toggleInteres(valor) {
    setIntereses((actual) =>
      actual.includes(valor) ? actual.filter((i) => i !== valor) : [...actual, valor]
    )
  }

  return (
    <form style={{ display: 'grid', gap: 12, maxWidth: 400 }}>
      <input value={texto} onChange={(e) => setTexto(e.target.value)} placeholder="Texto" />

      {/* Los inputs siempre devuelven strings: convierte a número */}
      <input type="number" min={1} value={cantidad} onChange={(e) => setCantidad(Number(e.target.value))} />

      <textarea value={mensaje} onChange={(e) => setMensaje(e.target.value)} rows={3} />

      <select value={pais} onChange={(e) => setPais(e.target.value)}>
        <option value="mx">México</option>
        <option value="co">Colombia</option>
        <option value="ar">Argentina</option>
      </select>

      {/* Checkbox: se usa checked y e.target.checked */}
      <label>
        <input type="checkbox" checked={acepta} onChange={(e) => setAcepta(e.target.checked)} />
        Acepto los términos
      </label>

      {/* Radios: mismo name, checked compara con el estado */}
      {['normal', 'express'].map((op) => (
        <label key={op}>
          <input type="radio" name="envio" value={op} checked={envio === op} onChange={(e) => setEnvio(e.target.value)} />
          Envío {op}
        </label>
      ))}

      {/* Varios checkboxes → arreglo */}
      {['React', 'Node', 'Diseño'].map((i) => (
        <label key={i}>
          <input type="checkbox" checked={intereses.includes(i)} onChange={() => toggleInteres(i)} />
          {i}
        </label>
      ))}

      <pre>{JSON.stringify({ texto, cantidad, mensaje, pais, acepta, envio, intereses }, null, 2)}</pre>
    </form>
  )
}

Un solo estado para todo el formulario ​

Con muchos campos, usa un objeto y el atributo name:

jsx
import { useState } from 'react'

const INICIAL = { nombre: '', email: '', telefono: '', plan: 'basico', newsletter: false }

export default function Registro() {
  const [form, setForm] = useState(INICIAL)

  // Un manejador para todos los campos
  function manejarCambio(e) {
    const { name, value, type, checked } = e.target
    setForm((f) => ({ ...f, [name]: type === 'checkbox' ? checked : value }))
  }

  function enviar(e) {
    e.preventDefault()
    console.log('Enviando', form)
    setForm(INICIAL) // limpiar
  }

  return (
    <form onSubmit={enviar}>
      <input name="nombre" value={form.nombre} onChange={manejarCambio} placeholder="Nombre" />
      <input name="email" type="email" value={form.email} onChange={manejarCambio} placeholder="Email" />
      <input name="telefono" value={form.telefono} onChange={manejarCambio} placeholder="Teléfono" />
      <select name="plan" value={form.plan} onChange={manejarCambio}>
        <option value="basico">Básico</option>
        <option value="pro">Pro</option>
      </select>
      <label>
        <input name="newsletter" type="checkbox" checked={form.newsletter} onChange={manejarCambio} />
        Recibir novedades
      </label>
      <button type="submit">Registrarme</button>
    </form>
  )
}

Validación y errores ​

jsx
import { useState } from 'react'
import './FormContacto.css'

const INICIAL = { nombre: '', email: '', mensaje: '' }

function validar({ nombre, email, mensaje }) {
  const errores = {}
  if (nombre.trim().length < 2) errores.nombre = 'Escribe tu nombre (mínimo 2 letras)'
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) errores.email = 'Escribe un email válido'
  if (mensaje.trim().length < 10) errores.mensaje = 'El mensaje debe tener al menos 10 caracteres'
  return errores
}

export default function FormContacto() {
  const [form, setForm] = useState(INICIAL)
  const [tocados, setTocados] = useState({}) // campos que el usuario ya visitó
  const [enviando, setEnviando] = useState(false)
  const [enviado, setEnviado] = useState(false)

  const errores = validar(form) // estado derivado: se recalcula en cada render
  const esValido = Object.keys(errores).length === 0

  const cambiar = (e) => setForm({ ...form, [e.target.name]: e.target.value })
  const tocar = (e) => setTocados({ ...tocados, [e.target.name]: true })

  async function enviar(e) {
    e.preventDefault()
    setTocados({ nombre: true, email: true, mensaje: true }) // muestra todos los errores
    if (!esValido) return

    setEnviando(true)
    await new Promise((r) => setTimeout(r, 1000)) // simula la petición a tu API
    setEnviando(false)
    setEnviado(true)
    setForm(INICIAL)
    setTocados({})
  }

  if (enviado) {
    return (
      <div className="exito">
        ✅ ¡Gracias! Te responderemos pronto.
        <button onClick={() => setEnviado(false)}>Enviar otro</button>
      </div>
    )
  }

  // Ayudante para mostrar el error de un campo sólo si ya fue tocado
  const error = (campo) => tocados[campo] && errores[campo]

  return (
    <form onSubmit={enviar} noValidate className="form">
      <label htmlFor="nombre">Nombre</label>
      <input
        id="nombre"
        name="nombre"
        value={form.nombre}
        onChange={cambiar}
        onBlur={tocar}
        aria-invalid={Boolean(error('nombre'))}
        aria-describedby="error-nombre"
      />
      {error('nombre') && <small id="error-nombre" className="error">{errores.nombre}</small>}

      <label htmlFor="email">Email</label>
      <input
        id="email"
        name="email"
        type="email"
        value={form.email}
        onChange={cambiar}
        onBlur={tocar}
        aria-invalid={Boolean(error('email'))}
      />
      {error('email') && <small className="error">{errores.email}</small>}

      <label htmlFor="mensaje">Mensaje</label>
      <textarea
        id="mensaje"
        name="mensaje"
        rows={4}
        value={form.mensaje}
        onChange={cambiar}
        onBlur={tocar}
        aria-invalid={Boolean(error('mensaje'))}
      />
      {error('mensaje') && <small className="error">{errores.mensaje}</small>}

      <button type="submit" disabled={enviando}>
        {enviando ? 'Enviando...' : 'Enviar mensaje'}
      </button>
    </form>
  )
}
css
.form { display: grid; gap: 6px; max-width: 420px; margin: 2rem auto; font-family: system-ui; }
.form input, .form textarea { padding: 10px; border: 1px solid #ccc; border-radius: 8px; font: inherit; }
.form [aria-invalid='true'] { border-color: #dc2626; }
.form .error { color: #dc2626; margin-bottom: 6px; }
.form button { margin-top: 12px; padding: 12px; border: 0; border-radius: 8px; background: #6d28d9; color: white; }
.form button:disabled { opacity: 0.6; }
.exito { max-width: 420px; margin: 2rem auto; padding: 16px; background: #dcfce7; border-radius: 8px; }

React 19: Actions en formularios ​

React 19 permite pasar una función a la prop action de un form. React la llama con un FormData al enviar, evita la recarga y, con useActionState, maneja el estado pendiente y el resultado por ti. Ideal para formularios que envían datos a un servidor.

jsx
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'

// Simula una API
async function suscribirApi(email) {
  await new Promise((r) => setTimeout(r, 800))
  if (email.endsWith('@test.com')) throw new Error('Ese dominio no está permitido')
  return { ok: true }
}

// La acción recibe (estadoAnterior, formData) y devuelve el nuevo estado
async function suscribir(estadoAnterior, formData) {
  const email = String(formData.get('email') ?? '').trim()
  if (!email.includes('@')) return { error: 'Email inválido', email }

  try {
    await suscribirApi(email)
    return { exito: `¡Listo! Te suscribiste con ${email}` }
  } catch (e) {
    return { error: e.message, email }
  }
}

// useFormStatus lee el estado del formulario padre (debe ir en un componente hijo)
function BotonEnviar() {
  const { pending } = useFormStatus()
  return <button disabled={pending}>{pending ? 'Enviando...' : 'Suscribirme'}</button>
}

export default function Newsletter() {
  const [estado, accion, pendiente] = useActionState(suscribir, {})

  return (
    <form action={accion} style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
      {/* No controlado: defaultValue conserva lo escrito si hubo error */}
      <input name="email" type="email" placeholder="tu@correo.com" defaultValue={estado.email} required />
      <BotonEnviar />
      {estado.error && <p role="alert" style={{ color: 'crimson', width: '100%' }}>{estado.error}</p>}
      {estado.exito && <p style={{ color: 'green', width: '100%' }}>{estado.exito}</p>}
      {pendiente && <p style={{ width: '100%' }}>Procesando…</p>}
    </form>
  )
}

INFO

Tras ejecutarse una acción, React reinicia los campos no controlados del formulario. Por eso devolvemos email en el estado y lo usamos como defaultValue cuando hay error. En Next.js esta misma API se usa con Server Actions.

Librerías para formularios grandes ​

Para formularios con muchos campos y validaciones complejas, en proyectos profesionales se usa React Hook Form + zod:

bash
npm i react-hook-form zod @hookform/resolvers
jsx
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'

const schema = z.object({
  email: z.email('Email inválido'),
  password: z.string().min(8, 'Mínimo 8 caracteres')
})

export default function Login() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting }
  } = useForm({ resolver: zodResolver(schema) })

  async function onSubmit(datos) {
    await new Promise((r) => setTimeout(r, 500))
    console.log(datos)
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>
      <input {...register('email')} placeholder="Email" />
      {errors.email && <small>{errors.email.message}</small>}
      <input type="password" {...register('password')} placeholder="Contraseña" />
      {errors.password && <small>{errors.password.message}</small>}
      <button disabled={isSubmitting}>Entrar</button>
    </form>
  )
}

Buena práctica

  • Siempre usa label asociado al input (htmlFor + id): mejora la accesibilidad y el área de clic.
  • Muestra los errores después de que el usuario sale del campo (onBlur) o al enviar, no mientras empieza a escribir.
  • La validación del frontend es para la experiencia de usuario; el backend debe validar siempre de nuevo.

Error común

Poner value sin onChange: el input queda "congelado" y React muestra una advertencia. Si quieres un valor inicial editable sin controlarlo, usa defaultValue.

Ejercicios ​

  1. Crea un formulario de registro con password y confirmar. Muestra un error si no coinciden y un indicador de fuerza (débil/media/fuerte) según longitud, mayúsculas, números y símbolos.
  2. Convierte el formulario de contacto a una versión con useActionState.
Ver solución
jsx
// 1
import { useState } from 'react'

function fuerza(pw) {
  let puntos = 0
  if (pw.length >= 8) puntos++
  if (/[A-Z]/.test(pw)) puntos++
  if (/[0-9]/.test(pw)) puntos++
  if (/[^A-Za-z0-9]/.test(pw)) puntos++
  return ['Muy débil', 'Débil', 'Media', 'Fuerte', 'Muy fuerte'][puntos]
}

export function RegistroPassword() {
  const [pw, setPw] = useState('')
  const [confirmar, setConfirmar] = useState('')
  const noCoinciden = confirmar.length > 0 && pw !== confirmar

  return (
    <form onSubmit={(e) => { e.preventDefault(); alert('¡Registrado!') }}>
      <input type="password" value={pw} onChange={(e) => setPw(e.target.value)} placeholder="Contraseña" />
      {pw && <p>Fuerza: {fuerza(pw)}</p>}
      <input type="password" value={confirmar} onChange={(e) => setConfirmar(e.target.value)} placeholder="Confirmar" />
      {noCoinciden && <p style={{ color: 'crimson' }}>Las contraseñas no coinciden</p>}
      <button disabled={!pw || pw !== confirmar}>Crear cuenta</button>
    </form>
  )
}
jsx
// 2
import { useActionState } from 'react'

async function enviarContacto(_, formData) {
  const datos = Object.fromEntries(formData) // { nombre, email, mensaje }
  const errores = {}
  if (datos.nombre.trim().length < 2) errores.nombre = 'Nombre muy corto'
  if (!datos.email.includes('@')) errores.email = 'Email inválido'
  if (datos.mensaje.trim().length < 10) errores.mensaje = 'Mensaje muy corto'
  if (Object.keys(errores).length) return { errores, datos }

  await new Promise((r) => setTimeout(r, 800)) // fetch('/api/contacto', ...)
  return { exito: true }
}

export function ContactoAction() {
  const [estado, accion, pendiente] = useActionState(enviarContacto, {})
  if (estado.exito) return <p>✅ ¡Mensaje enviado!</p>
  const d = estado.datos ?? {}
  const e = estado.errores ?? {}

  return (
    <form action={accion}>
      <input name="nombre" defaultValue={d.nombre} placeholder="Nombre" />
      {e.nombre && <small>{e.nombre}</small>}
      <input name="email" defaultValue={d.email} placeholder="Email" />
      {e.email && <small>{e.email}</small>}
      <textarea name="mensaje" defaultValue={d.mensaje} />
      {e.mensaje && <small>{e.mensaje}</small>}
      <button disabled={pendiente}>{pendiente ? 'Enviando…' : 'Enviar'}</button>
    </form>
  )
}

Resumen ​

  • Input controlado: value + onChange conectados al estado. Checkbox usa checked.
  • Un objeto de estado + name + un manejador genérico simplifican formularios grandes.
  • Valida con una función pura, muestra errores tras onBlur y deshabilita el botón al enviar.
  • React 19: form action={fn} + useActionState + useFormStatus para formularios que envían datos.
  • Para formularios complejos: React Hook Form + zod.

Academia Web — aprende, copia y construye.