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.
Busca en todas las páginas de la documentación
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.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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.
// 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:
useOptimistic con un actualizador de estilo reductor para acciones de agregar y eliminarmessages realuseOptimistic(serverState, updaterFn) devuelve [optimisticState, setOptimistic]setOptimistic(value), React aplica la función actualizadora para producir un estado temporalserverState del prop/padreserverState originaluseOptimistic solo funciona dentro de una transition (form action o startTransition)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;
}
);// 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[]Solo funciona en transitions - Llamar a setOptimistic fuera de una form action o startTransition no tiene efecto. Solución: Asegúrate de llamarlo dentro de una form action async o envuelve con startTransition.
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 useState regular 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 revalidatePath o revalidateTag) para que el estado optimista se resuelva correctamente.
| 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 |
[optimisticState, setOptimistic]optimisticState refleja el estado de UI temporal mientras una acción está pendientesetOptimistic(value) dentro de una form action o startTransition para aplicar la actualización optimistaserverState realuseOptimistic solo produce estado temporal durante una transition pendientesetOptimistic fuera de una form action o startTransition no tiene efectostartTransition solo es necesario fuera de formulariosconst message = { ...data, id: `temp-${Date.now()}`, pending: true };
addOptimistic({ type: "add", message });
// En la UI:
{msg.pending && <span className="text-blue-500">Enviando...</span>}pending a elementos optimistas y diseña los diferentes (opacidad, etiqueta)(currentState, actionPayload) y devuelve el nuevo estado optimista{ type: "add"; message: Message } | { type: "delete"; id: string }revalidatePath o revalidateTag en la server action para desencadenar un re-renderizadoconst [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;
});(state: T, action: A) => T - el tipo de retorno coincide con el tipo de estadouseState con try/catch en su lugarreset() inmediatamente después de setOptimistic limpia el campo de entradaRevisado por Chris St. John·Última actualización: 10 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥