Next.js
Formularios con Server Actions
Explica formularios accesibles con Server Actions, FormData, validación por capas, useActionState, pending, errores, concurrencia y progressive enhancement.
- Última actualización
- Actualizada
- Nivel
- Aplicación
Next.js
Explica formularios accesibles con Server Actions, FormData, validación por capas, useActionState, pending, errores, concurrencia y progressive enhancement.
Los formularios con Server Actions combinan semántica HTML, progressive enhancement y mutaciones servidor. Un buen formulario no solo envía datos: comunica labels, pending, errores, éxito y conflictos mientras el servidor continúa siendo la autoridad de validación y permisos.
React permite pasar una Server Action al atributo action:
<form action={createCustomer}>
<label htmlFor="name">Nombre</label>
<input id="name" name="name" required />
<button>Crear cliente</button>
</form>El navegador construye FormData y React coordina la ejecución. Desde un Server Component, el form puede enviarse incluso antes de que JavaScript cliente termine de cargar.
native form submit
↓
Server Action
↓
result / redirect / updated RSC treeUn formulario HTML posee comportamiento antes de añadir Client Components:
name.Server Actions se integran con ese modelo.
export function CreatePostForm() {
return (
<form action={createPostAction}>
<label htmlFor="title">Título</label>
<input id="title" name="title" required minLength={5} />
<button>Publicar</button>
</form>
);
}Si la Action redirige después del éxito, este formulario puede funcionar sin state cliente.
Campos de texto llegan como valores que debes normalizar:
function stringField(formData: FormData, name: string) {
const value = formData.get(name);
return typeof value === "string" ? value.trim() : "";
}Casos:
getAll.File.Object.fromEntries conserva solo uno.No uses Object.fromEntries si la estructura admite repetidos sin procesarlos explícitamente.
<input type="email" required minlength="5" />Mejora feedback inmediato.
Puede validar interacciones complejas, formato progresivo y previews.
Es obligatoria porque el cliente puede omitirse o manipularse.
Restricciones unique, foreign key y checks protegen concurrencia e integridad final.
HTML UX
→ client UX
→ server trust boundary
→ database integrityconst createCustomerSchema = z.object({
name: z.string().trim().min(2).max(100),
email: z.string().trim().toLowerCase().email(),
});Action:
"use server";
export async function createCustomerAction(
_previousState: CreateCustomerState,
formData: FormData,
): Promise<CreateCustomerState> {
const parsed = createCustomerSchema.safeParse({
name: formData.get("name"),
email: formData.get("email"),
});
if (!parsed.success) {
return {
status: "invalid",
errors: parsed.error.flatten().fieldErrors,
values: {
name: String(formData.get("name") ?? ""),
email: String(formData.get("email") ?? ""),
},
};
}
// authenticate, authorize, persist...
return { status: "success" };
}Devuelve valores permitidos para preservar inputs no sensibles. Nunca devuelvas contraseñas.
Permite conectar resultado, Action y pending:
"use client";
import { useActionState } from "react";
import { createCustomerAction } from "./actions";
const initialState: CreateCustomerState = { status: "idle" };
export function CreateCustomerForm() {
const [state, formAction, isPending] = useActionState(
createCustomerAction,
initialState,
);
return (
<form action={formAction} noValidate>
{/* fields */}
<button disabled={isPending}>
{isPending ? "Creando…" : "Crear cliente"}
</button>
</form>
);
}La firma de la Action cambia: recibe previousState antes de FormData.
noValidate solo es apropiado si reemplazas conscientemente validación nativa; normalmente conserva las capacidades HTML.
type CreateCustomerState =
| { status: "idle" }
| {
status: "invalid";
errors: {
name?: string[];
email?: string[];
};
values: { name: string; email: string };
}
| { status: "duplicate"; message: string }
| { status: "success"; customerId: string };Esto evita combinaciones como success=true y error simultáneo.
const nameErrorId = "name-error";
<input
id="name"
name="name"
defaultValue={state.status === "invalid" ? state.values.name : ""}
aria-invalid={Boolean(state.status === "invalid" && state.errors.name)}
aria-describedby={state.status === "invalid" && state.errors.name ? nameErrorId : undefined}
/>
{state.status === "invalid" && state.errors.name && (
<p id={nameErrorId}>{state.errors.name.join(" ")}</p>
)}aria-invalid comunica estado; aria-describedby conecta la explicación.
No uses role="alert" en cada campo durante cada pulsación. Puede saturar lectores de pantalla. Un resumen de errores tras submit puede usar una live region y mover foco cuando sea apropiado.
Un descendiente del form puede leer pending:
"use client";
import { useFormStatus } from "react-dom";
export function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "Guardando…" : "Guardar"}
</button>
);
}Debe renderizarse dentro del form cuyo estado necesita. El componente que crea el form no puede llamar useFormStatus para leerse a sí mismo antes de estar bajo ese contexto.
<form action={saveDraft}>
{/* fields */}
<button type="submit">Guardar borrador</button>
<button type="submit" formAction={publishPost}>
Publicar
</button>
</form>Cada intención puede tener Action distinta.
Ambas deben validar y autorizar. No confíes en el texto del botón para decidir una operación crítica dentro de una única Action sin validación.
const updateWithId = updateCustomer.bind(null, customerId);
<form action={updateWithId}>...</form>Oculta el ID del markup visible, pero no lo convierte en seguro.
Hidden:
<input type="hidden" name="customerId" value="..." />Es más visible e interoperable. Ambos requieren autorización.
<input name="name" defaultValue={customer.name} />DOM conserva valor; FormData lo recoge. Reduce state cliente.
<input value={name} onChange={(event) => setName(event.target.value)} />Aporta formato, validación interactiva y coordinación.
No conviertas todos los campos en controlados si solo necesitas leerlos al submit.
Después de éxito, un form HTML puede resetearse automáticamente bajo ciertos flujos de Action. Para control explícito:
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
if (state.status === "success") {
formRef.current?.reset();
}
}, [state.status]);No limpies antes de confirmar persistencia. En edición, quizá no quieras resetear en absoluto.
Mueve foco al resumen o primer campo inválido:
useEffect(() => {
if (state.status === "invalid") {
firstInvalidRef.current?.focus();
}
}, [state]);role="status".No muevas foco arbitrariamente si la UI apenas cambia.
Deshabilita el botón y campos que no deben cambiar, no toda la página.
Un botón disabled no anuncia necesariamente por qué. Cambia el label y usa aria-busy en la región:
<form aria-busy={isPending}>...</form>Evita spinners sin texto.
disabled reduce clicks, pero no protege contra request repetida.
Para creación sensible:
<input type="hidden" name="idempotencyKey" value="generated-key" />El servidor usa una restricción única. La key debe generarse de forma estable para ese intento, no en cada render.
Un email puede pasar validación y ser creado concurrentemente por otra request.
La DB necesita unique constraint. La Action traduce el error:
if (isUniqueViolation(error, "customer_email_key")) {
return {
status: "duplicate",
message: "Ya existe un cliente con ese correo.",
};
}No hagas únicamente “SELECT antes de INSERT”; existe una carrera.
Incluye versión:
<input type="hidden" name="version" value={customer.version} />El update usa WHERE id = ? AND version = ?. Si no afecta filas, devuelve conflicto:
otro usuario guardó primero
→ no sobrescribir silenciosamente
→ ofrecer recargar o compararconst file = formData.get("document");
if (!(file instanceof File)) return invalid;Valida:
file.size.Para archivos grandes, genera una URL firmada y sube directo a object storage. La Action confirma metadata después.
useOptimistic puede mostrar un resultado esperado antes de confirmación:
const [optimisticItems, addOptimisticItem] = useOptimistic(
items,
(current, item: Item) => [...current, { ...item, pending: true }],
);Solo aporta cuando:
Para pagos o eliminación crítica, confirma o usa optimismo visual limitado.
No devuelvas state y llames redirect esperando que el cliente vea ambos; redirect interrumpe el flujo.
Un form Server Component con Action puede enviarse. Un form cliente antes de hydration puede encolarse hasta que React cargue.
Prueba progressive enhancement cuando sea requisito. Componentes controlados, previews y optimismo naturalmente dependen de JavaScript.
form
→ native constraints
→ Action schema
→ session + permission
→ transaction
→ unique constraints
→ updateTag suppliers
→ redirect supplier detailEstados esperados:
Un fallo de DB activa boundary y logging, no un mensaje falso de validación.
Schema y mapping de errores.
Action con DB de prueba, unique y autorización.
Labels, pending, errores asociados y focus.
Submit válido, inválido, doble click, red lenta, redirect, back/refresh y sin permiso.
Teclado, lector de pantalla, live regions y zoom.
Deshabilita JavaScript si el producto lo exige.
Elimina progressive enhancement y recrea pending manual.
Permite bypass.
Usuarios de lector no saben qué campo falló.
Bloquea navegación por una operación local.
Pierde datos ante error.
No protege concurrencia; usa constraint.
Es manipulable.
No evita requests repetidas.
required no protege una Action?Optimistic UI con useOptimistic profundiza en estado temporal, IDs, rollback y reconciliación con la respuesta autoritativa.