Formularios
Maneja la entrada del usuario con componentes controlados, refs no controlados o acciones de formulario de React 19.
Receta
Tarjeta de receta de referencia rápida — lista para copiar y pegar.
// Input controlado
const [email, setEmail] = useState("");
<input value={email} onChange={e => setEmail(e.target.value)} />
// Input no controlado con ref
const inputRef = useRef<HTMLInputElement>(null);
<input ref={inputRef} defaultValue="" />
// Léelo después: inputRef.current?.value
// Acción de formulario de React 19
async function createUser(formData: FormData) {
const name = formData.get("name") as string;
await saveToDatabase(name);
}
<form action={createUser}>
<input name="name" required />
<button type="submit">Crear</button>
</form>
// useActionState de React 19
const [state, formAction, isPending] = useActionState(submitFn, initialState);Cuándo recurrir a esto: cada vez que recopilas entrada del usuario — formularios de inicio de sesión, barras de búsqueda, páginas de configuración, asistentes de varios pasos.
Ejemplo Práctico
"use client";
import { useState, useActionState } from "react";
// --- Formulario controlado ---
export function ControlledSignup() {
const [form, setForm] = useState({ name: "", email: "", role: "viewer" });
const [submitted, setSubmitted] = useState(false);
function updateField(field: string, value: string) {
setForm(prev => ({ ...prev, [field]: value }));
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSubmitted(true);
}
if (submitted) {
return (
<div className="rounded bg-green-50 p-4 text-green-800">
¡Bienvenido, {form.name}! Enviamos una confirmación a {form.email}.
</div>
);
}
return (
<form onSubmit={handleSubmit} className="max-w-sm space-y-3 rounded border p-4">
<div>
<label htmlFor="name" className="block text-sm font-medium">Nombre</label>
<input
id="name"
value={form.name}
onChange={e => updateField("name", e.target.value)}
required
className="w-full rounded border px-3 py-1"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium">Correo electrónico</label>
<input
id="email"
type="email"
value={form.email}
onChange={e => updateField("email", e.target.value)}
required
className="w-full rounded border px-3 py-1"
/>
</div>
<div>
<label htmlFor="role" className="block text-sm font-medium">Rol</label>
<select
id="role"
value={form.role}
onChange={e => updateField("role", e.target.value)}
className="w-full rounded border px-3 py-1"
>
<option value="viewer">Visor</option>
<option value="editor">Editor</option>
<option value="admin">Administrador</option>
</select>
</div>
<button type="submit" className="rounded bg-blue-600 px-4 py-2 text-white">
Registrarse
</button>
</form>
);
}
// --- Acción de formulario de React 19 con useActionState ---
interface FormState {
message: string;
error: boolean;
}
async function submitFeedback(
prevState: FormState,
formData: FormData
): Promise<FormState> {
const feedback = formData.get("feedback") as string;
// Simula el retardo del servidor
await new Promise(resolve => setTimeout(resolve, 1000));
if (feedback.length < 10) {
return { message: "Los comentarios deben tener al menos 10 caracteres.", error: true };
}
return { message: `¡Gracias por tus comentarios!`, error: false };
}
export function FeedbackForm() {
const [state, formAction, isPending] = useActionState(submitFeedback, {
message: "",
error: false,
});
return (
<form action={formAction} className="max-w-sm space-y-3 rounded border p-4">
<label htmlFor="feedback" className="block text-sm font-medium">
Tus comentarios
</label>
<textarea
id="feedback"
name="feedback"
required
rows={3}
className="w-full rounded border px-3 py-1"
placeholder="Dinos qué piensas..."
/>
{state.message && (
<p className={state.error ? "text-sm text-red-600" : "text-sm text-green-600"}>
{state.message}
</p>
)}
<button
type="submit"
disabled={isPending}
className="rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
>
{isPending ? "Enviando..." : "Enviar comentarios"}
</button>
</form>
);
}Qué demuestra esto:
- Formulario controlado con
value+onChangepara cada input useActionState(React 19) gestionando el estado del envío del formulario, el indicador de pendiente y la validación- La API
FormDatapara leer los valores del formulario sin estado controlado - Emparejamiento accesible de
label+htmlFor - Botón de envío deshabilitado durante la acción asíncrona
Análisis Profundo
Cómo Funciona
- Los componentes controlados mantienen los valores de los inputs en el estado de React — el componente es la fuente de la verdad, y el input refleja
valueen cada render - Los componentes no controlados dejan que el DOM tenga el valor — lo lees bajo demanda mediante un ref o
FormData - Las acciones de formulario de React 19 te permiten pasar una función
asynca<form action={fn}>. React gestiona el envío, proporcionaisPendinge integra conuseActionStatepara manejar los valores de retorno useActionState(actionFn, initialState)devuelve[state, wrappedAction, isPending]— llama aactionFn(prevState, formData)al enviar y actualiza el estado con el resultado
Controlado vs No Controlado vs Acciones
| Enfoque | Fuente de la Verdad | Ideal Para |
|---|---|---|
Controlado (value + onChange) | Estado de React | Validación en tiempo real, campos condicionales, inputs interdependientes complejos |
No controlado (defaultValue + ref) | DOM | Formularios simples, integraciones de terceros, inputs sensibles al rendimiento |
| Acciones de formulario (React 19) | FormData | Mutaciones del lado del servidor, mejora progresiva, reducción del estado del cliente |
Parámetros y Valores de Retorno
useActionState:
| Parámetro | Tipo | Descripción |
|---|---|---|
action | (prevState: T, formData: FormData) => T or Promise<T> | Función llamada al enviar el formulario |
initialState | T | Estado inicial antes del primer envío |
permalink? | string | URL opcional para mejora progresiva con SSR |
| Retorno | Tipo | Descripción |
|---|---|---|
state | T | Estado actual (actualizado tras completarse la acción) |
formAction | (formData: FormData) => void | Acción envuelta para pasar a <form action> |
isPending | boolean | true mientras la acción se está ejecutando |
Variaciones
No controlado con FormData (sin necesidad de useState):
function SearchForm() {
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const query = formData.get("query") as string;
router.push(`/search?q=${encodeURIComponent(query)}`);
}
return (
<form onSubmit={handleSubmit}>
<input name="query" defaultValue="" />
<button type="submit">Buscar</button>
</form>
);
}useFormStatus para botones de envío anidados:
import { useFormStatus } from "react-dom";
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "Guardando..." : "Guardar"}
</button>
);
}
// Uso — SubmitButton debe ser un hijo de un <form>
<form action={saveAction}>
<input name="title" />
<SubmitButton />
</form>Actualizaciones optimistas con useOptimistic:
import { useOptimistic } from "react";
function MessageList({ messages, sendAction }: Props) {
const [optimisticMessages, addOptimistic] = useOptimistic(
messages,
(state, newMessage: string) => [
...state,
{ id: "temp", text: newMessage, sending: true },
]
);
async function handleSubmit(formData: FormData) {
const text = formData.get("message") as string;
addOptimistic(text);
await sendAction(formData);
}
return (
<form action={handleSubmit}>
<ul>
{optimisticMessages.map(msg => (
<li key={msg.id} style={{ opacity: msg.sending ? 0.5 : 1 }}>
{msg.text}
</li>
))}
</ul>
<input name="message" />
<button type="submit">Enviar</button>
</form>
);
}Notas de TypeScript
// Tipar onChange para diferentes tipos de input
function handleChange(e: React.ChangeEvent<HTMLInputElement>) { ... }
function handleSelectChange(e: React.ChangeEvent<HTMLSelectElement>) { ... }
function handleTextareaChange(e: React.ChangeEvent<HTMLTextAreaElement>) { ... }
// Tipar el estado de la acción de formulario
interface ActionState {
success: boolean;
errors: Record<string, string>;
}
async function myAction(
prev: ActionState,
formData: FormData
): Promise<ActionState> {
// valida y devuelve el nuevo estado
}Aspectos a Tener en Cuenta
-
Falta
valueuonChange— EstablecervaluesinonChangehace que el input sea de solo lectura. React advierte sobre esto. Solución: añade un manejadoronChangeo usadefaultValuepara inputs no controlados. -
defaultValueno se actualiza — CambiardefaultValuedespués del montaje no tiene efecto, porque solo establece el valor inicial del DOM. Solución: usavaluecontrolado si necesitas que React gestione las actualizaciones, o añade unakeypara volver a montar el input. -
checkedde casillas y radios — Estos usanchecked/defaultChecked, novalue/defaultValue. Solución:<input type="checkbox" checked={isOn} onChange={e => setIsOn(e.target.checked)} />. -
Los inputs numéricos devuelven strings —
e.target.valuesiempre es un string, incluso para<input type="number">. Solución: conviértelo de forma explícita:Number(e.target.value)oparseInt(e.target.value, 10). -
useFormStatus fuera de un formulario —
useFormStatussolo funciona cuando el componente se renderiza como descendiente de un<form>. Llamarlo en el mismo componente que renderiza el<form>devuelve datos obsoletos. Solución: extrae el botón de envío a un componente hijo.
Alternativas
| Alternativa | Úsalo Cuando | No lo Uses Cuando |
|---|---|---|
| React Hook Form | Validación compleja, muchos campos, formularios críticos para el rendimiento | Formularios simples con 1-3 campos |
| Zod + Server Actions | Mutaciones del servidor validadas por esquema en Next.js | Formularios solo de cliente sin servidor |
| Formik | Proyectos heredados que ya lo usan | Proyectos nuevos (React Hook Form es más ligero) |
<dialog> nativo + <form method="dialog"> | Diálogos de confirmación modales | Recopilación de datos de varios campos |
Preguntas Frecuentes
¿Cuál es la diferencia entre inputs controlados y no controlados?
- Controlado: el estado de React gestiona el input mediante
value+onChange— React es la fuente de la verdad - No controlado: el DOM tiene el valor mediante
defaultValue— léelo bajo demanda con un ref oFormData - Usa controlado para validación en tiempo real; no controlado para formularios simples o inputs sensibles al rendimiento
¿Qué son las acciones de formulario de React 19?
Puedes pasar una función async directamente a <form action={fn}>. React la llama con un objeto FormData al enviar, gestiona el estado pendiente e integra con useActionState para manejar los valores de retorno — sin necesidad de e.preventDefault().
¿Qué es useActionState y cómo funciona?
useActionState(actionFn, initialState) devuelve [state, formAction, isPending]. Al enviar, llama a actionFn(prevState, formData) y actualiza state con el resultado. isPending es true mientras la acción se ejecuta.
¿Por qué mi input controlado es de solo lectura?
Establecer value sin un manejador onChange hace que el input no sea editable — React fija el valor al estado. Añade onChange para actualizar el estado, o cambia a defaultValue para un input no controlado.
¿Por qué cambiar defaultValue no actualiza mi input?
defaultValue solo establece el valor inicial del DOM al montar. Los cambios posteriores al montaje no tienen efecto. Usa value controlado si React necesita gestionar las actualizaciones, o añade una prop key para forzar el remontaje.
¿Cómo manejo las casillas de verificación y los botones de radio?
Usa checked / defaultChecked en lugar de value / defaultValue:
<input
type="checkbox"
checked={isEnabled}
onChange={e => setIsEnabled(e.target.checked)}
/>¿Cómo obtengo un número de un input numérico?
e.target.value siempre es un string, incluso para <input type="number">. Conviértelo de forma explícita: Number(e.target.value) o parseInt(e.target.value, 10).
¿Qué es useFormStatus y dónde puedo usarlo?
useFormStatus() devuelve { pending } para mostrar el estado de carga durante una acción de formulario. Debe llamarse en un componente que sea hijo de un <form> — no en el mismo componente que renderiza el formulario.
¿Cómo manejo varios campos de formulario sin un useState separado para cada uno?
Usa un único objeto de estado y una función de actualización genérica:
const [form, setForm] = useState({ name: "", email: "" });
function updateField(field: string, value: string) {
setForm(prev => ({ ...prev, [field]: value }));
}¿Cuándo debería usar una biblioteca de formularios como React Hook Form?
- Más de 3-5 campos con reglas de validación
- Validación interdependiente compleja (el campo B depende del campo A)
- Formularios críticos para el rendimiento donde importan los re-renders por cada pulsación de tecla
- Para formularios simples con 1-3 campos, los inputs controlados sencillos o las acciones de formulario son suficientes
¿Cómo implemento actualizaciones optimistas con formularios en React 19?
Usa useOptimistic para mostrar inmediatamente el resultado esperado mientras la acción se procesa. Si la acción falla, React revierte automáticamente al estado real.
¿Cómo hago que los formularios sean accesibles?
- Empareja cada input con un
<label htmlFor="id">que coincida con eliddel input - Usa
required,aria-describedbypara los mensajes de error yaria-invalidpara los campos inválidos - Asegúrate de que los mensajes de error se anuncien a los lectores de pantalla
Relacionado
- Eventos — el manejo de eventos general sobre el que se construyen los formularios
- Refs — acceso a los valores de inputs no controlados
- useState — impulsando los inputs controlados
- Componentes — componiendo componentes de campos de formulario