Acciones de Formulario - Conecta funciones async a formularios con pending y error state integrados
Receta
"use client";
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
async function subscribe(_prev: string, formData: FormData): Promise<string> {
const email = formData.get("email") as string;
const res = await fetch("/api/subscribe", {
method: "POST",
body: JSON.stringify({ email }),
});
if (!res.ok) return "Algo salió mal.";
return "¡Suscrito!";
}
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "Suscribiendo..." : "Suscribirse"}
</button>
);
}
export default function NewsletterForm() {
const [message, formAction, isPending] = useActionState(subscribe, "");
return (
<form action={formAction}>
<input name="email" type="email" required placeholder="you@example.com" />
<SubmitButton />
{message && <p>{message}</p>}
</form>
);
}Cuándo usarlo: Siempre que tengas un formulario que envíe datos -- registros, búsquedas, operaciones CRUD. Las acciones de formulario reemplazan el patrón manual onSubmit + preventDefault + useState.
Ejemplo en Funcionamiento
// Un formulario de contacto con validación, visualización de errores y mejora progresiva
"use client";
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
type FormState = {
success: boolean;
errors: Record<string, string>;
message: string;
};
const initialState: FormState = {
success: false,
errors: {},
message: "",
};
async function submitContact(_prev: FormState, formData: FormData): Promise<FormState> {
const name = formData.get("name") as string;
const email = formData.get("email") as string;
const body = formData.get("body") as string;
// Validación del lado del cliente
const errors: Record<string, string> = {};
if (!name || name.length < 2) errors.name = "El nombre debe tener al menos 2 caracteres.";
if (!email || !email.includes("@")) errors.email = "Por favor ingresa un email válido.";
if (!body || body.length < 10) errors.body = "El mensaje debe tener al menos 10 caracteres.";
if (Object.keys(errors).length > 0) {
return { success: false, errors, message: "Por favor corrige los errores abajo." };
}
try {
const res = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email, body }),
});
if (!res.ok) throw new Error("Error del servidor");
return { success: true, errors: {}, message: "¡Mensaje enviado! Nos pondremos en contacto." };
} catch {
return { success: false, errors: {}, message: "Falló el envío. Por favor intenta de nuevo." };
}
}
function FieldError({ error }: { error?: string }) {
if (!error) return null;
return <p className="text-red-500 text-sm mt-1">{error}</p>;
}
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50"
>
{pending ? "Enviando..." : "Enviar Mensaje"}
</button>
);
}
export default function ContactForm() {
const [state, formAction, isPending] = useActionState(submitContact, initialState);
return (
<form action={formAction} className="space-y-4 max-w-md">
<div>
<label htmlFor="name">Nombre</label>
<input id="name" name="name" className="border w-full p-2 rounded" />
<FieldError error={state.errors.name} />
</div>
<div>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" className="border w-full p-2 rounded" />
<FieldError error={state.errors.email} />
</div>
<div>
<label htmlFor="body">Mensaje</label>
<textarea id="body" name="body" rows={4} className="border w-full p-2 rounded" />
<FieldError error={state.errors.body} />
</div>
<SubmitButton />
{state.message && (
<p className={state.success ? "text-green-600" : "text-red-600"}>
{state.message}
</p>
)}
</form>
);
}Lo que esto demuestra:
useActionStatemanejando state de formulario a través de envíos (errores, mensaje de éxito)useFormStatusen un componente hijo para mostrar el estado pending en el botón de envío- Visualización de errores a nivel de campo desde el valor devuelto por la action
- Mejora progresiva -- el formulario funciona sin JavaScript si se empareja con una server action
Análisis Profundo
Cómo Funciona
<form action={fn}>-- React 19 extiende el atributoactionnativo para aceptar funciones async. Cuando se envía el formulario, React llama afn(formData)y gestiona el ciclo de vida pending.useActionState(action, initialState, permalink?)-- Envuelve una función action y devuelve[state, wrappedAction, isPending]. Cada vez que se envía el formulario, se llama aaction(prevState, formData)y el valor devuelto se convierte en el nuevostate. El parámetro opcionalpermalinkhabilita la mejora progresiva para server actions.useFormStatus()-- Debe ser llamado desde un componente renderizado dentro de un<form>. Devuelve{ pending, data, method, action }reflejando el estado de envío del formulario padre más cercano.- Mejora progresiva -- Al usar server actions con
useActionState, los formularios pueden funcionar antes de que se cargue JavaScript. El argumentopermalinkespecifica dónde redirigir después de que se complete la server action en el caso sin JS. isPendingdeuseActionStaterefleja si la action se está ejecutando actualmente. Esto está disponible en el mismo componente que llama auseActionState, a diferencia deuseFormStatusque solo funciona en descendientes.
Variaciones
Múltiples botones de envío con diferentes acciones:
"use client";
import { useActionState } from "react";
export default function ItemForm() {
const [saveResult, saveAction] = useActionState(saveItem, null);
const [deleteResult, deleteAction] = useActionState(deleteItem, null);
return (
<form>
<input name="name" />
<button formAction={saveAction}>Guardar</button>
<button formAction={deleteAction}>Eliminar</button>
</form>
);
}Usando useFormStatus para un indicador de carga global:
"use client";
import { useFormStatus } from "react-dom";
export function FormProgress() {
const { pending, data } = useFormStatus();
if (!pending) return null;
return (
<div className="fixed top-0 left-0 w-full h-1 bg-blue-500 animate-pulse" />
);
}
// Usa dentro de cualquier formulario
<form action={myAction}>
<FormProgress />
{/* ... fields ... */}
</form>Reiniciar formulario después de éxito:
"use client";
import { useActionState, useRef, useEffect } from "react";
export default function ResetableForm() {
const [state, formAction, isPending] = useActionState(submitData, { success: false });
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
if (state.success) {
formRef.current?.reset();
}
}, [state]);
return (
<form ref={formRef} action={formAction}>
<input name="item" required />
<button type="submit" disabled={isPending}>Agregar</button>
</form>
);
}Notas de TypeScript
useActionStatees genérica:useActionState<State>(fn: (prev: State, formData: FormData) => Promise<State>, initial: State).useFormStatusdevuelve{ pending: boolean; data: FormData | null; method: string; action: string | ((formData: FormData) => void) | null }.- La firma de la función action es
(previousState: State, formData: FormData) => State | Promise<State>. - El parámetro
permalinkenuseActionStatese tipea comostring | undefined.
Trampas
useFormStatusdebe estar dentro de un descendiente de<form>-- Llamarla en el mismo componente que renderiza la etiqueta<form>devuelve{ pending: false }siempre. Solución: Extrae el botón de envío en un componente hijo.- El valor de retorno de action reemplaza state completamente -- A diferencia de un reducer, el valor de retorno es el state completo nuevo, no una actualización parcial. Solución: Expande el state anterior si quieres hacer merge:
return { ...prev, error: "..." }. - El formulario no se reinicia automáticamente -- A diferencia de los envíos de formulario nativos, las acciones de formulario de React no reinician el formulario en caso de éxito. Solución: Usa un
useRefen el formulario y llama aformRef.current?.reset()en un effect. - Ubicación de importación de
useActionState-- Se importa desde"react", no desde"react-dom". El antiguouseFormStateestaba en"react-dom"y ahora está deprecated. Solución:import { useActionState } from "react". - Inputs no controlados después de action -- Si tu action devuelve errores pero usas inputs no controlados, los valores del usuario se preservan en el DOM. Si usas inputs controlados, debes restaurarlos desde state. Solución: Prefiere inputs no controlados con acciones de formulario, o sincroniza state desde
formData. - Múltiples formularios en la misma página -- Cada llamada a
useActionStatees independiente. Enviar un formulario no afecta a otro. Solución: Este es el comportamiento esperado pero ten en cuenta queuseFormStatussolo refleja el formulario ancestro más cercano.
Alternativas
| Enfoque | Cuándo elegir |
|---|---|
| Form Actions + useActionState | Formularios React 19 estándar con pending state integrado |
| react-hook-form | Validación compleja del lado del cliente, arrays de campos, comportamiento watch |
| Formik | Proyectos legacy que ya usan Formik |
| Manual onSubmit + fetch | Control total sobre el ciclo de vida de la solicitud, headers personalizados |
| Server action only (sin client state) | Mutaciones simples que redirigen después de completarse |
| Conform | Mejora progresiva con integración Zod para server actions |
FAQs
¿Cuál es la diferencia entre pasar una función a form action versus usar onSubmit?
<form action={fn}>permite que React gestione el pending state, manejo de errores y actualizaciones optimistas automáticamente- El patrón tradicional
onSubmit+preventDefault+useStaterequiere gestión manual de state - Las acciones de formulario también habilitan la mejora progresiva cuando se emparejan con server actions
¿Qué devuelve useActionState y cómo lo usas?
- Devuelve
[state, wrappedAction, isPending] statese actualiza cada vez que se completa la action, basado en lo que devuelve la actionwrappedActionse pasa a<form action={}>eisPendingindica si la action se está ejecutando actualmente
¿De dónde se importa useFormStatus y cómo difiere del isPending de useActionState?
useFormStatusse importa desde"react-dom", mientras queuseActionStatese importa desde"react"useFormStatusdebe ser llamado desde un componente renderizado dentro de un<form>(un descendiente)- El
isPendingdeuseActionStateestá disponible en el mismo componente que renderiza el formulario
¿Cómo muestras errores de validación a nivel de campo con acciones de formulario?
Devuelve un objeto errors desde la action y renderízalo por campo:
async function submit(_prev: State, formData: FormData) {
const errors: Record<string, string> = {};
if (!formData.get("name")) errors.name = "Requerido";
if (Object.keys(errors).length) return { errors };
// ... guardar datos
return { errors: {} };
}¿Cómo reiniciaas un formulario después de un envío exitoso?
const formRef = useRef<HTMLFormElement>(null);
const [state, formAction] = useActionState(submit, initial);
useEffect(() => {
if (state.success) formRef.current?.reset();
}, [state]);
<form ref={formRef} action={formAction}>...</form>Las acciones de formulario de React no reinician el formulario automáticamente a diferencia de los envíos de formulario nativos.
¿Puedes tener múltiples botones de envío con diferentes acciones en el mismo formulario?
Sí. Usa el atributo formAction en botones individuales:
<form>
<input name="name" />
<button formAction={saveAction}>Guardar</button>
<button formAction={deleteAction}>Eliminar</button>
</form>¿Qué es el parámetro permalink en useActionState?
- Es una URL de string opcional que habilita la mejora progresiva para server actions
- Cuando JavaScript aún no se ha cargado, el formulario se envía a esta URL y se ejecuta la server action
- Después de completarse, el navegador redirige al permalink
Trampa: ¿Por qué useFormStatus siempre devuelve pending: false en mi componente?
useFormStatusdebe ser llamado desde un descendiente del elemento<form>, no del mismo componente que lo renderiza- Extrae el botón de envío en un componente hijo separado que llame a
useFormStatus
Trampa: El valor de retorno de action reemplaza state completamente en lugar de hacer merge. ¿Cómo manejo actualizaciones parciales?
- A diferencia de un reducer, el valor de retorno es el state completo nuevo, no una actualización parcial
- Expande el state anterior si quieres hacer merge:
return { ...prev, error: "..." } - Diseña la forma de tu state para que sea completamente devuelta desde cada camino de action
¿Cómo tipas el hook useActionState en TypeScript?
useActionState<State>(
fn: (prev: State, formData: FormData) => Promise<State>,
initial: State
): [State, (formData: FormData) => void, boolean]La firma de la función action es (previousState: State, formData: FormData) => State | Promise<State>.
¿Cómo se tipea useFormStatus en TypeScript?
- Devuelve
{ pending: boolean; data: FormData | null; method: string; action: string | ((formData: FormData) => void) | null } dataes elFormDatasiendo enviado (o null cuando no está pending)methodrefleja el método HTTP del formulario
¿Debo usar inputs controlados o no controlados con acciones de formulario?
- Prefiere inputs no controlados con acciones de formulario -- los valores del usuario se preservan en el DOM entre envíos
- Si usas inputs controlados, debes restaurar sus valores desde state cuando la action devuelve errores
- Los inputs no controlados reducen el boilerplate y funcionan naturalmente con
FormData
Relacionado
- Server Actions -- Funciones de servidor que las acciones de formulario pueden invocar
- useOptimistic -- Agregando actualizaciones optimistas a envíos de formulario
- Overview -- Lista completa de características de React 19