Next.js
Optimistic UI y consistencia
Explica cómo diseñar Optimistic UI con identidad temporal, reconciliación, rollback, idempotencia y control de conflictos sin fingir éxito definitivo.
- Última actualización
- Actualizada
- Nivel
- Aplicación
Next.js
Explica cómo diseñar Optimistic UI con identidad temporal, reconciliación, rollback, idempotencia y control de conflictos sin fingir éxito definitivo.
Optimistic UI muestra inmediatamente el resultado probable de una mutación, pero no cambia la autoridad del servidor. La interfaz temporal debe poder reconciliar éxito, fallo, duplicados, respuestas fuera de orden y conflictos sin presentar una confirmación falsa como definitiva.
El flujo es:
confirmed server state
↓ user intent
optimistic operation
↓
temporary UI
├─ server success → replace/reconcile
└─ server failure → rollback or mark faileduseOptimistic ayuda a calcular la versión temporal durante una Action o transition. No proporciona persistencia, transacción, autorización ni idempotencia.
Sin optimismo, cada interacción espera la red:
click → pending → server → UI changesEn acciones frecuentes como enviar un mensaje o marcar favorito, esa espera hace que el producto se sienta lento aunque la mutation tarde pocos cientos de milisegundos.
Con optimismo:
click → UI changes immediately → server confirmsEl beneficio es perceptivo y de continuidad. El tiempo real de la operación no disminuye.
Ejemplos:
Puedes mostrar pending inmediato sin afirmar éxito.
"use client";
import { useOptimistic } from "react";
type Message = {
id: string;
text: string;
status: "sent" | "sending" | "failed";
};
export function Messages({ messages }: { messages: Message[] }) {
const [optimisticMessages, addOptimisticMessage] = useOptimistic(
messages,
(current, message: Message) => [...current, message],
);
async function send(formData: FormData) {
const text = String(formData.get("text") ?? "").trim();
if (!text) return;
const clientId = crypto.randomUUID();
addOptimisticMessage({
id: clientId,
text,
status: "sending",
});
await sendMessageAction({ clientId, text });
}
return (
<>
<MessageList messages={optimisticMessages} />
<form action={send}>{/* ... */}</form>
</>
);
}La función transformadora debe ser pura: recibe estado actual y operación, devuelve el temporal.
Una creación todavía no tiene ID servidor. Usa un clientId estable por intento:
clientId
→ key de UI
→ idempotency key
→ correlación con server resultNo uses índice ni Math.random() durante cada render. Genera el ID al iniciar la intención.
El servidor puede guardar o mapear clientId para evitar duplicados.
Cuando llega el resultado autoritativo:
optimistic item { clientId, sending }
↓ server result
confirmed item { serverId, clientId, sent }La nueva prop messages del servidor reemplaza la base de useOptimistic. Evita insertar manualmente otra copia además de revalidar; podrías duplicar el elemento.
Un fallo puede:
Adecuado para un like poco importante, acompañado de un mensaje.
Adecuado para mensajes:
"Hola" — No enviado — ReintentarAdecuado para reordenamientos.
La estrategia debe permitir que el usuario entienda qué ocurrió y recupere su intención.
Usa estilos y texto:
<li aria-label={`${message.text}, ${message.status}`}>
{message.text}
{message.status === "sending" && <span>Enviando…</span>}
</li>No dependas únicamente de opacidad o color.
"use server";
export async function sendMessageAction(input: SendMessageInput) {
const session = await requireSession();
const parsed = sendMessageSchema.parse(input);
await messageService.createIdempotent({
userId: session.user.id,
clientId: parsed.clientId,
text: parsed.text,
});
updateTag(`conversation:${parsed.conversationId}`);
}Optimismo cliente y read-your-own-writes servidor trabajan juntos. La Action sigue validando y autorizando.
Dos submits pueden producir la misma operación. El clientId debe tener unique constraint dentro del scope correcto.
same user + same clientId
→ return existing messageDeshabilitar el botón ayuda, pero no protege contra retry o request duplicada.
Cantidad del carrito:
set 2 → request A
set 3 → request B
B completes
A completes laterSi el servidor aplica “set quantity” sin versión, A puede sobrescribir B.
Soluciones:
useOptimistic no ordena mutations servidor.
El usuario edita un registro con versión 4, pero servidor está en 5:
optimistic UI looks saved
→ server returns conflict
→ restore draft
→ show latest version and merge optionsNo reemplaces silenciosamente trabajo de otro usuario. Devuelve un estado conflict tipado.
const [optimisticItems, moveItem] = useOptimistic(items, reorder);El servidor debe persistir una representación robusta:
Enviar índices simples puede fallar con ediciones concurrentes.
Puedes ocultar un elemento mientras la Action corre, pero ofrece undo si el dominio lo permite.
Una eliminación real debería ser soft delete o tener una ventana de recuperación cuando el riesgo es alto.
No optimices una eliminación que podría ser rechazada por dependencias o permisos sin feedback visible.
role="status" para confirmaciones no urgentes.role="alert" para fallo que requiere atención.const [optimisticTasks, toggleOptimistic] = useOptimistic(
tasks,
(current, taskId: string) =>
current.map((task) =>
task.id === taskId
? { ...task, completed: !task.completed, pending: true }
: task,
),
);Action:
await toggleTaskAuthorized({ taskId, expectedVersion });
updateTag(`task:${taskId}`);Si existe conflicto, la UI vuelve al snapshot servidor y muestra que la tarea cambió desde otro dispositivo.
Puede engañar al usuario.
Provoca remounts y duplicados.
Duplica la entidad.
La UI queda falsa.
Presenta éxito antes de autoridad.
Retries crean duplicados.
Una respuesta vieja sobrescribe intención nueva.
clientId puede servir también como idempotency key?Route Handlers cambia desde mutations ligadas a React hacia contratos HTTP para webhooks, APIs y archivos.