Formularios Optimistas
Usa useOptimistic con formularios para mostrar retroalimentación instantánea mientras se procesan las server actions — con UI pendiente y reversión automática al fallar.
Receta
Tarjeta de receta de referencia rápida — lista para copiar y pegar.
"use client";
import { useOptimistic, useActionState } from "react";
type Todo = { id: string; text: string; completed: boolean };
function TodoList({
todos,
toggleAction,
}: {
todos: Todo[];
toggleAction: (formData: FormData) => Promise<void>;
}) {
const [optimisticTodos, setOptimistic] = useOptimistic(
todos,
(state, toggledId: string) =>
state.map((t) => (t.id === toggledId ? { ...t, completed: !t.completed } : t))
);
return (
<ul>
{optimisticTodos.map((todo) => (
<li key={todo.id}>
<form
action={async (formData) => {
setOptimistic(todo.id);
await toggleAction(formData);
}}
>
<input type="hidden" name="id" value={todo.id} />
<button type="submit" className={todo.completed ? "line-through opacity-50" : ""}>
{todo.text}
</button>
</form>
</li>
))}
</ul>
);
}Cuándo usarlo: Cuando es probable que la server action tenga éxito y quieres que la UI se sienta instantánea — alternancia, me gusta, eliminar o reordenar elementos.
Ejemplo Funcional
// app/actions/messages.ts
"use server";
import { revalidatePath } from "next/cache";
export type Message = {
id: string;
text: string;
author: string;
createdAt: string;
pending?: boolean;
};
export async function addMessage(prevState: any, formData: FormData) {
const text = formData.get("text") as string;
if (!text?.trim()) return { error: "El mensaje no puede estar vacío" };
// Simula retraso de red
await new Promise((r) => setTimeout(r, 1500));
// Simula fallo ocasional
if (Math.random() < 0.2) {
return { error: "Fallo al enviar. Intenta de nuevo." };
}
await db.message.create({
data: { text, author: "You", createdAt: new Date().toISOString() },
});
revalidatePath("/chat");
return { success: true };
}
export async function deleteMessage(formData: FormData) {
const id = formData.get("id") as string;
await db.message.delete({ where: { id } });
revalidatePath("/chat");
}// app/chat/page.tsx
"use client";
import { useOptimistic, useActionState, useRef } from "react";
import { addMessage, deleteMessage, type Message } from "@/app/actions/messages";
export function ChatRoom({ messages }: { messages: Message[] }) {
const formRef = useRef<HTMLFormElement>(null);
const [optimisticMessages, addOptimistic] = useOptimistic(
messages,
(state, action: { type: "add"; message: Message } | { type: "delete"; id: string }) => {
if (action.type === "add") return [...state, action.message];
if (action.type === "delete") return state.filter((m) => m.id !== action.id);
return state;
}
);
const [sendState, sendAction, isSending] = useActionState(
async (prev: any, formData: FormData) => {
const text = formData.get("text") as string;
addOptimistic({
type: "add",
message: {
id: `temp-${Date.now()}`,
text,
author: "You",
createdAt: new Date().toISOString(),
pending: true,
},
});
formRef.current?.reset();
return addMessage(prev, formData);
},
null
);
return (
<div className="mx-auto max-w-lg">
<div className="space-y-3 rounded border p-4" style={{ minHeight: 300 }}>
{optimisticMessages.map((msg) => (
<div
key={msg.id}
className={`flex items-start justify-between rounded p-2 ${
msg.pending ? "bg-blue-50 opacity-60" : "bg-gray-50"
}`}
>
<div>
<span className="text-xs font-medium text-gray-500">{msg.author}</span>
<p className="text-sm">{msg.text}</p>
{msg.pending && <span className="text-xs text-blue-500">Enviando...</span>}
</div>
{!msg.pending && (
<form
action={async (formData) => {
addOptimistic({ type: "delete", id: msg.id });
await deleteMessage(formData);
}}
>
<input type="hidden" name="id" value={msg.id} />
<button type="submit" className="text-xs text-red-400 hover:text-red-600">
Eliminar
</button>
</form>
)}
</div>
))}
</div>
{sendState?.error && (
<p className="mt-2 text-sm text-red-600">{sendState.error}</p>
)}
<form ref={formRef} action={sendAction} className="mt-3 flex gap-2">
<input
name="text"
placeholder="Escribe un mensaje..."
className="flex-1 rounded border p-2"
required
/>
<button
type="submit"
disabled={isSending}
className="rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
>
Enviar
</button>
</form>
</div>
);
}Qué demuestra esto:
useOptimisticcon un actualizador de estilo reductor para acciones de agregar y eliminar- Estado visual pendiente (opacidad, etiqueta "Enviando...")
- Reversión automática cuando falla la server action — React revierte al prop
messagesreal - Reinicio del formulario inmediatamente después de la actualización optimista
- Pantalla de error para acciones fallidas
Análisis Profundo
Cómo Funciona
useOptimistic(serverState, updaterFn)devuelve[optimisticState, setOptimistic]- Cuando se llama a
setOptimistic(value), React aplica la función actualizadora para producir un estado temporal - El estado optimista se muestra mientras la acción async (form action o transition) está pendiente
- Cuando la acción se completa, React reemplaza el estado optimista con el nuevo
serverStatedel prop/padre - Si la acción falla, el estado optimista se revierte automáticamente al
serverStateoriginal useOptimisticsolo funciona dentro de una transition (form action ostartTransition)
Variaciones
Botón "Me gusta" con contador optimista:
function LikeButton({ postId, likes, isLiked }: { postId: string; likes: number; isLiked: boolean }) {
const [optimistic, setOptimistic] = useOptimistic(
{ likes, isLiked },
(state, _: void) => ({
likes: state.isLiked ? state.likes - 1 : state.likes + 1,
isLiked: !state.isLiked,
})
);
return (
<form action={async () => {
setOptimistic(undefined);
await toggleLike(postId);
}}>
<button type="submit">
{optimistic.isLiked ? "heart-filled" : "heart"} {optimistic.likes}
</button>
</form>
);
}Reorden optimista:
const [optimisticItems, reorder] = useOptimistic(
items,
(state, { from, to }: { from: number; to: number }) => {
const next = [...state];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
return next;
}
);Notas de TypeScript
// useOptimistic es genérico
const [state, setState] = useOptimistic<Message[], { type: "add"; message: Message }>(
messages,
(state, action) => {
// action está tipado como { type: "add"; message: Message }
return [...state, action.message];
}
);
// La función actualizadora debe devolver el mismo tipo que el primer argumento
// (state: Message[], action: Action) => Message[]Obstáculos
-
Solo funciona en transitions — Llamar a
setOptimisticfuera de una form action ostartTransitionno tiene efecto. Solución: Asegúrate de llamarlo dentro de una form action async o envuelve constartTransition. -
La reversión reemplaza todo el estado — Cuando falla la acción, todo el estado optimista se revierte, no solo el elemento fallido. Este es el comportamiento correcto pero puede sorprenderte si hay múltiples acciones en vuelo.
-
Sin API de reversión manual — No puedes revertir manualmente el estado optimista. Solución: La reversión automática maneja los fallos. Para control manual, usa
useStateregular con try/catch. -
Cierre obsoleto en el actualizador — El actualizador recibe el estado optimista actual (incluyendo actualizaciones optimistas previas), por lo que las llamadas secuenciales se componen correctamente. Pero evita cerrar sobre estado externo.
-
Se requiere re-renderización del Server Component — Después de que se complete la acción, el Server Component padre debe re-renderizarse con datos nuevos (vía
revalidatePathorevalidateTag) para que el estado optimista se resuelva correctamente.
Alternativas
| Alternativa | Úsalo Cuando | No lo Uses Cuando |
|---|---|---|
useState + try/catch | Necesitas control manual de reversión | Quieres reversión automática |
SWR optimisticData | Usas SWR para obtención de datos | Usas Server Components y actions |
TanStack Query onMutate | Usas TanStack Query con actualizaciones optimistas | Usas el patrón App Router |
| Sin UI optimista | La acción es rápida (menos de 200ms) o el fallo es probable | Los usuarios perciben lag y la acción generalmente tiene éxito |
Preguntas Frecuentes
¿Qué devuelve useOptimistic y cómo lo usas?
- Devuelve
[optimisticState, setOptimistic] optimisticStaterefleja el estado de UI temporal mientras una acción está pendiente- Llama a
setOptimistic(value)dentro de una form action ostartTransitionpara aplicar la actualización optimista
¿Cómo funciona la reversión automática cuando falla una server action?
- Cuando se completa la acción (éxito o fallo), React reemplaza el estado optimista con el prop
serverStatereal - Si la acción falla, los cambios optimistas se descartan y la UI se revierte automáticamente
- No necesitas escribir ninguna lógica de reversión tú mismo
¿Por qué setOptimistic debe ser llamado dentro de una transition?
useOptimisticsolo produce estado temporal durante una transition pendiente- Llamar a
setOptimisticfuera de una form action ostartTransitionno tiene efecto - Las form actions crean transitions automáticamente, así que envolver en
startTransitionsolo es necesario fuera de formularios
¿Cómo muestras un estado visual "pendiente" para elementos optimistas?
const message = { ...data, id: `temp-${Date.now()}`, pending: true };
addOptimistic({ type: "add", message });
// En la UI:
{msg.pending && <span className="text-blue-500">Enviando...</span>}- Agrega un flag
pendinga elementos optimistas y diseña los diferentes (opacidad, etiqueta)
¿Cómo funciona la función actualizadora de estilo reductor en useOptimistic?
- El actualizador recibe
(currentState, actionPayload)y devuelve el nuevo estado optimista - Usa una unión discriminada para tipos de acción:
{ type: "add"; message: Message } | { type: "delete"; id: string } - Las llamadas secuenciales se componen correctamente porque cada una recibe el estado optimista más reciente
Obstáculo: ¿Qué sucede cuando hay múltiples acciones optimistas en vuelo y una falla?
- La reversión reemplaza todo el estado optimista, no solo el elemento fallido
- Todos los cambios optimistas pendientes se revierten cuando React se resuelve al estado real del servidor
- Este es el comportamiento esperado pero puede sorprenderte si se están modificando múltiples elementos
Obstáculo: ¿Por qué useOptimistic requiere que el Server Component padre se re-renderice?
- Después de que se complete la acción, los datos reales deben provenir del servidor vía un nuevo valor de prop
- Llama a
revalidatePathorevalidateTagen la server action para desencadenar un re-renderizado - Sin revalidación, el estado optimista se resuelve a datos obsoletos
¿Cómo tipas el hook useOptimistic con genéricos de TypeScript?
const [state, setState] = useOptimistic<
Message[],
{ type: "add"; message: Message } | { type: "delete"; id: string }
>(messages, (state, action) => {
// action está completamente tipado
if (action.type === "add") return [...state, action.message];
if (action.type === "delete") return state.filter(m => m.id !== action.id);
return state;
});¿Por qué la función actualizadora debe devolver el mismo tipo que el primer argumento?
- TypeScript hace cumplir que
(state: T, action: A) => T— el tipo de retorno coincide con el tipo de estado - Esto previene devolver accidentalmente una forma diferente
- El parámetro genérico asegura consistencia de tipo entre el estado del servidor y el estado optimista
¿Cuándo deberías omitir la UI optimista y usar estados de carga regular en su lugar?
- Omite la UI optimista cuando la acción es rápida (menos de 200ms) o el fallo es probable
- Úsala cuando la acción generalmente tiene éxito y los usuarios perciben lag (ej. alternancia, me gusta, eliminar)
- Para control manual de reversión, usa
useStatecon try/catch en su lugar
¿Cómo proporciona formRef.current?.reset() retroalimentación instantánea?
- Llamar a
reset()inmediatamente después desetOptimisticlimpia el campo de entrada - El usuario ve su mensaje aparecer en la lista y la entrada se limpia instantáneamente
- Si la acción falla, el mensaje optimista desaparece vía reversión automática
Relacionado
- Server Action Forms — server actions y useActionState
- Form Patterns Basic — patrones de formularios estándar
- Form Error Display — mostrando errores de acciones fallidas
- Form Accessibility — anunciando cambios de estado