En las primeras versiones de React, la gestión de formularios se convirtió en una de las áreas más sobrecargadas y propensas a código repetitivo. El patrón estándar predominante eran los "componentes controlados" (Controlled Components), donde cada pulsación de tecla disparaba un evento onChange, actualizaba el estado local mediante useState y desencadenaba un re-renderizado completo del componente.
En aplicaciones corporativas a gran escala, esto exigía recurrir a pesadas bibliotecas externas (Formik, React Hook Form), provocaba una alta presión de memoria por asignaciones frecuentes y rompía por completo la Mejora Progresiva (Progressive Enhancement): sin descargar el bundle de JavaScript, el formulario resultaba totalmente inerte.
React 19 acomete un giro arquitectónico hacia los estándares de la plataforma web al introducir las React Actions. Las Actions son funciones de transición asíncronas integradas de forma nativa con los elementos HTML <form>, <input> y <button>.
Internamente, React 19 automatiza cuatro aspectos clave del ciclo de vida de mutación:
isPending integrados y el hook contextual useFormStatus para componentes hijos.Bajo el paradigma de React 19, un formulario se modela como una máquina de estados finitos declarativa. La prop action recibe una función que acepta una instancia nativa de FormData (o argumentos arbitrarios) en lugar de un React.FormEvent sintético, ejecutándose concurrentemente en TransitionLane.
[ El usuario pulsa "Enviar" ]
│
▼
[ Despacho de la Action (useActionState) ]
│
├───► [ useOptimistic: La UI muta al instante con el estado proyectado ]
│
├───► [ useFormStatus: El botón pasa a pending = true ]
│
▼ (Ejecución asíncrona: Red / Server Action)
┌────────┴────────┐
│ │
(Éxito 200 OK) (Fallo 500 / Error de red)
│ │
▼ ▼
[ Commit de estado ] [ Rollback automático en useOptimistic ]
[ isPending = false ] [ Renderizado del error en useActionState ]El hook useOptimistic crea una capa transitoria superpuesta al estado base confirmado. Mientras la transición asíncrona está en vuelo, la interfaz renderiza esta proyección predictiva. Cuando la promesa se resuelve con éxito, el estado confirmado asume el control y la capa optimista se repliega. Si la operación falla (reject), React descarta automáticamente la capa optimista, restaurando la UI al último estado confirmado sin necesidad de código de rollback manual.
A diferencia de los patrones antiguos donde el desarrollador debía capturar instantáneas en bloques try/catch y restaurar el estado manualmente ante errores de red, useOptimistic está acoplado al ciclo de vida de la transición. Un fallo en la promesa cancela la transacción y restituye el estado confirmado de forma limpia.
El usuario ejecuta el submit. React empaqueta los campos con nombre en una instancia nativa de FormData.
React DOM propaga useFormStatus (pending = true) a todos los elementos hijos del formulario sin prop drilling.
El componente invoca updateOptimistic. La interfaz refleja el cambio de inmediato antes del tránsito por la red.
useActionState procesa la función asíncrona en TransitionLane. Los datos se envían al servidor o se resuelven localmente.
El estado confirmado sustituye la capa optimista. Si la red falla, la proyección optimista se elimina limpiamente.
¿Qué sucede al enviar un formulario configurado con <form action={myServerAction}> si el JavaScript del cliente aún no se ha descargado o está desactivado?
El usuario confirma la acción. El navegador genera un FormData a partir de los controles de entrada con nombre.
El manejador ejecuta dispatchOptimistic(). La UI dibuja el elemento proyectado de forma síncrona antes de la llamada de red.
Se ejecuta la petición o Server Action. El contexto useFormStatus deshabilita los botones de envío.
Con éxito, la respuesta del servidor se convierte en estado canónico. Ante error, la capa optimista revierte sin dejar rastro.
A continuación se muestra un componente de producción que integra useActionState, useOptimistic, useFormStatus y formAction nativo con tipado estricto en TypeScript:
import React, { useActionState, useOptimistic } from 'react';
import { useFormStatus } from 'react-dom';
// 1. Contratos de datos
export interface TaskItem {
id: string;
title: string;
priority: 'low' | 'medium' | 'high';
createdAt: number;
}
export interface TaskActionState {
tasks: TaskItem[];
error: string | null;
lastCreatedId: string | null;
}
// 2. Componente de botón aislado con useFormStatus
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="px-4 py-2 bg-accent-cyan text-bg-surface font-semibold rounded-md disabled:opacity-50 flex items-center gap-2"
>
{pending ? (
<>
<span className="animate-spin inline-block w-4 h-4 border-2 border-current border-t-transparent rounded-full" />
Guardando...
</>
) : (
'Crear tarea'
)}
</button>
);
}
// 3. Manejador de Action asíncrona
async function taskActionHandler(
prevState: TaskActionState,
formData: FormData
): Promise<TaskActionState> {
const title = formData.get('title') as string | null;
const priority = (formData.get('priority') as TaskItem['priority']) || 'medium';
// Validación de datos
if (!title || title.trim().length < 3) {
return {
...prevState,
error: 'El título de la tarea debe tener al menos 3 caracteres.',
};
}
try {
// Simulación de mutación de red
await new Promise((resolve, reject) => {
setTimeout(() => {
// Fallo simulado para comprobar la reversión en Optimistic UI
if (title.toLowerCase().includes('error')) {
reject(new Error('Fallo de red al persistir la tarea.'));
} else {
resolve(true);
}
}, 1000);
});
const newTask: TaskItem = {
id: crypto.randomUUID(),
title: title.trim(),
priority,
createdAt: Date.now(),
};
return {
tasks: [newTask, ...prevState.tasks],
error: null,
lastCreatedId: newTask.id,
};
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Error desconocido del servidor.';
return {
...prevState,
error: message,
};
}
}
// 4. Componente principal del gestor de tareas
export function TaskManager({ initialTasks }: { initialTasks: TaskItem[] }) {
const initialState: TaskActionState = {
tasks: initialTasks,
error: null,
lastCreatedId: null,
};
// Gestión del ciclo de vida de la Action
const [state, formAction, isPending] = useActionState(taskActionHandler, initialState);
// Capa optimista
const [optimisticTasks, addOptimisticTask] = useOptimistic(
state.tasks,
(currentTasks, pendingTitle: string): TaskItem[] => [
{
id: `optimistic-${Date.now()}`,
title: pendingTitle,
priority: 'medium',
createdAt: Date.now(),
},
...currentTasks,
]
);
const handleSubmit = async (formData: FormData) => {
const rawTitle = formData.get('title') as string;
if (rawTitle && rawTitle.trim().length >= 3) {
// Inyección optimista instantánea
addOptimisticTask(rawTitle);
}
// Delegar al despachador de useActionState
await formAction(formData);
};
return (
<div className="task-manager p-6 max-w-xl mx-auto space-y-6">
<h2 className="text-2xl font-bold text-text-primary">Gestión de tareas (React 19)</h2>
{state.error && (
<div className="p-3 bg-red-900/30 border border-red-500 rounded-md text-red-200 text-sm">
{state.error}
</div>
)}
{/* El formulario utiliza la prop nativa action sin onSubmit */}
<form action={handleSubmit} className="space-y-4">
<div>
<label htmlFor="title" className="block text-sm font-medium mb-1">
Título de la tarea
</label>
<input
id="title"
name="title"
type="text"
required
placeholder="Introduce el título..."
className="w-full px-3 py-2 border border-border rounded-md bg-bg-surface"
/>
</div>
<div>
<label htmlFor="priority" className="block text-sm font-medium mb-1">
Prioridad
</label>
<select
id="priority"
name="priority"
className="w-full px-3 py-2 border border-border rounded-md bg-bg-surface"
>
<option value="low">Baja</option>
<option value="medium">Media</option>
<option value="high">Alta</option>
</select>
</div>
<div className="flex items-center justify-between">
<SubmitButton />
{isPending && (
<span className="text-xs text-text-muted">Sincronizando con el servidor...</span>
)}
</div>
</form>
<div className="task-list space-y-2">
<h3 className="text-lg font-semibold">Tareas registradas</h3>
{optimisticTasks.map(task => {
const isOptimistic = task.id.startsWith('optimistic-');
return (
<div
key={task.id}
className={`p-3 border rounded-md flex justify-between items-center transition-all ${
isOptimistic
? 'opacity-50 border-dashed border-accent-cyan bg-accent-cyan/5'
: 'border-border bg-bg-surface'
}`}
>
<div>
<span className="font-medium">{task.title}</span>
{isOptimistic && (
<span className="ml-2 text-[10px] px-1.5 py-0.5 rounded bg-accent-cyan/20 text-accent-cyan">
Optimista
</span>
)}
</div>
<span className="text-xs text-text-muted">{task.priority}</span>
</div>
);
})}
</div>
</div>
);
}¿Por qué invocar const { pending } = useFormStatus() devuelve false si se ejecuta en el mismo componente que renderiza la etiqueta <form>?
Para homogeneizar las signaturas de las actions en bases de código corporativas, implemente este constructor con validación Zod:
import { z } from 'zod';
export interface ActionResponse<T> {
data: T | null;
errors: Record<string, string[]> | null;
serverError: string | null;
success: boolean;
}
export function createFormAction<TSchema extends z.ZodTypeAny, TResult>(
schema: TSchema,
handler: (data: z.infer<TSchema>) => Promise<TResult>
) {
return async (
prevState: ActionResponse<TResult>,
formData: FormData
): Promise<ActionResponse<TResult>> => {
const rawData = Object.fromEntries(formData.entries());
const validation = schema.safeParse(rawData);
if (!validation.success) {
return {
data: null,
errors: validation.error.flatten().fieldErrors,
serverError: null,
success: false,
};
}
try {
const result = await handler(validation.data);
return {
data: result,
errors: null,
serverError: null,
success: true,
};
} catch (err: unknown) {
const errorMsg = err instanceof Error ? err.message : 'Error crítico en la ejecución de la acción.';
return {
data: null,
errors: null,
serverError: errorMsg,
success: false,
};
}
};
}El hook useFormStatus obtiene su estado desde un contexto de React DOM provisto por el elemento <form> a sus hijos. Invocar useFormStatus dentro del componente que renderiza <form> devuelve siempre { pending: false, data: null } porque busca un formulario ancestro en el árbol superior.
// ❌ INCORRECTO: Hook invocado al mismo nivel que <form>, pending siempre es false
export function BadForm() {
const { pending } = useFormStatus(); // ¡No funciona!
return (
<form action={myAction}>
<button disabled={pending}>Enviar</button>
</form>
);
}
// ✅ CORRECTO: Extraer el botón a un subcomponente hijo independiente
function SubmitBtn() {
const { pending } = useFormStatus(); // Lee correctamente el contexto ancestro
return <button disabled={pending}>Enviar</button>;
}
export function GoodForm() {
return (
<form action={myAction}>
<SubmitBtn />
</form>
);
}Al migrar código antiguo, los programadores suelen conservar onSubmit={(e) => e.preventDefault()}. Esto desactiva el cauce nativo de Actions de React 19: el formulario no genera el FormData, no activa useFormStatus y pierde la capacidad de Progressive Enhancement.
// ❌ INCORRECTO: preventDefault anula el ciclo de vida de React 19 Actions
<form
action={formAction}
onSubmit={(e) => {
e.preventDefault(); // ¡Interrumpe la ejecución de la Action!
}}
>El despachador que devuelve useOptimistic solo puede invocarse dentro de un startTransition activo o en el manejador de una action vinculada a <form action={...}>. Ejecutarlo en un manejador síncrono estándar (como un onClick común) provocará que React descarte de inmediato la capa optimista al no poder enlazarla con una transición en curso.
Dado que React 19 promueve formularios no controlados, las restricciones de validación HTML5 (required, type="email") pueden omitirse fácilmente mediante llamadas HTTP directas o modificación del DOM. La action debe validar siempre el formData mediante esquemas en el servidor (como Zod) y devolver los errores correspondientes en ActionState.