Tema
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:
| Controlado | No controlado | |
|---|---|---|
| Quién guarda el valor | El estado de React | El DOM |
| Cómo se lee | La variable de estado | FormData o una ref al enviar |
| Ventajas | Validación en vivo, formatear mientras escribes, deshabilitar botones | Menos código, menos renders |
| Uso típico | Formularios interactivos | Formularios 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/resolversjsx
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
labelasociado 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
- Crea un formulario de registro con
passwordyconfirmar. 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. - 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+onChangeconectados al estado. Checkbox usachecked. - Un objeto de estado +
name+ un manejador genérico simplifican formularios grandes. - Valida con una función pura, muestra errores tras
onBlury deshabilita el botón al enviar. - React 19:
form action={fn}+useActionState+useFormStatuspara formularios que envían datos. - Para formularios complejos: React Hook Form + zod.