Actualizaciones optimistas de UI: instantáneas y con reversión
Implementa actualizaciones optimistas que se sienten instantáneas: muestra el estado esperado, reconcilia con el servidor y revierte si algo falla.

Por qué usar actualizaciones optimistas
Toda solicitud de red introduce latencia. Cuando un usuario hace clic en "me gusta", esperar 200 ms a que el servidor responda antes de actualizar la interfaz se siente lento. Las actualizaciones optimistas aplican el resultado esperado de inmediato, haciendo que la interfaz se sienta instantánea. Si el servidor rechaza la mutación, la interfaz vuelve al estado anterior.
El patrón básico
El flujo central es: guardar el estado actual, aplicar el cambio optimista, enviar la mutación y confirmar o revertir.
// ❌ Pessimistic — user waits for server round trip
async function handleLike(postId: string): Promise<void> {
setLoading(true);
const result = await fetch(`/api/posts/${postId}/like`, { method: "POST" });
if (result.ok) {
setLiked(true);
setLikeCount((c) => c + 1);
}
setLoading(false);
}
// ✅ Optimistic — instant feedback with rollback
async function handleLike(postId: string): Promise<void> {
// 1. Save previous state for rollback
const previousLiked = liked;
const previousCount = likeCount;
// 2. Apply optimistic update immediately
setLiked(true);
setLikeCount((c) => c + 1);
try {
// 3. Send mutation to server
const result = await fetch(`/api/posts/${postId}/like`, {
method: "POST",
});
if (!result.ok) throw new Error("Like failed");
// 4. Optionally reconcile with server data
const data = await result.json();
setLikeCount(data.likeCount); // Use server's canonical count
} catch {
// 5. Rollback on failure
setLiked(previousLiked);
setLikeCount(previousCount);
toast.error("Failed to like post. Please try again.");
}
}Un hook optimista genérico
Extrae el patrón en un hook reutilizable que gestione cualquier mutación optimista con reversión automática.
import { useState, useCallback, useRef } from "react";
interface OptimisticOptions<T> {
onError?: (error: Error, previousState: T) => void;
onSuccess?: (result: unknown, currentState: T) => void;
}
function useOptimistic<T>(
initialState: T,
options?: OptimisticOptions<T>
): {
state: T;
isPending: boolean;
update: (optimisticState: T, mutation: () => Promise<unknown>) => void;
} {
const [state, setState] = useState(initialState);
const [isPending, setIsPending] = useState(false);
const rollbackRef = useRef<T>(initialState);
const update = useCallback(
async (optimisticState: T, mutation: () => Promise<unknown>) => {
// Store rollback point
rollbackRef.current = state;
setState(optimisticState);
setIsPending(true);
try {
const result = await mutation();
options?.onSuccess?.(result, optimisticState);
} catch (error) {
// Rollback to previous state
setState(rollbackRef.current);
options?.onError?.(error as Error, rollbackRef.current);
} finally {
setIsPending(false);
}
},
[state, options]
);
return { state, isPending, update };
}Listas optimistas: agregar y eliminar elementos
Las operaciones sobre listas —agregar, eliminar, reordenar— son las que más se benefician de las actualizaciones optimistas, porque es donde la latencia resulta más perceptible.
interface Todo {
id: string;
text: string;
completed: boolean;
}
function TodoList() {
const [todos, setTodos] = useState<Todo[]>([]);
async function addTodo(text: string): Promise<void> {
// Generate a temporary ID for the optimistic item
const tempId = `temp-${crypto.randomUUID()}`;
const optimisticTodo: Todo = {
id: tempId,
text,
completed: false,
};
// Add immediately
setTodos((prev) => [...prev, optimisticTodo]);
try {
const response = await fetch("/api/todos", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }),
});
const created = await response.json();
// Replace temp item with server-created item (gets real ID)
setTodos((prev) =>
prev.map((t) => (t.id === tempId ? created : t))
);
} catch {
// Remove the optimistic item
setTodos((prev) => prev.filter((t) => t.id !== tempId));
toast.error("Failed to add todo");
}
}
async function removeTodo(id: string): Promise<void> {
const previousTodos = [...todos];
// Remove immediately
setTodos((prev) => prev.filter((t) => t.id !== id));
try {
await fetch(`/api/todos/${id}`, { method: "DELETE" });
} catch {
// Restore the item
setTodos(previousTodos);
toast.error("Failed to remove todo");
}
}
return (
<ul>
{todos.map((todo) => (
<li
key={todo.id}
style={{
opacity: todo.id.startsWith("temp-") ? 0.6 : 1,
}}
>
{todo.text}
<button onClick={() => removeTodo(todo.id)}>×</button>
</li>
))}
</ul>
);
}useOptimistic de React 19
React 19 incluye un hook useOptimistic integrado que simplifica este patrón, especialmente cuando se usan Server Actions.
"use client";
import { useOptimistic, useTransition } from "react";
import { toggleLike } from "@/actions/likes";
interface Post {
id: string;
title: string;
liked: boolean;
likeCount: number;
}
function PostCard({ post }: { post: Post }) {
const [isPending, startTransition] = useTransition();
const [optimisticPost, setOptimisticPost] = useOptimistic(
post,
(currentPost: Post, newLiked: boolean) => ({
...currentPost,
liked: newLiked,
likeCount: currentPost.likeCount + (newLiked ? 1 : -1),
})
);
function handleToggleLike() {
startTransition(async () => {
setOptimisticPost(!optimisticPost.liked);
await toggleLike(post.id);
});
}
return (
<article>
<h2>{optimisticPost.title}</h2>
<button onClick={handleToggleLike} disabled={isPending}>
{optimisticPost.liked ? "❤️" : "🤍"} {optimisticPost.likeCount}
</button>
</article>
);
}Cómo gestionar actualizaciones optimistas concurrentes
Cuando los usuarios disparan varias mutaciones con rapidez —alternando "me gusta", reordenando elementos—, es necesario gestionar los estados optimistas concurrentes sin que se corrompan.
class OptimisticQueue<T> {
private pendingUpdates: Map<string, {
previous: T;
optimistic: T;
mutation: Promise<unknown>;
}> = new Map();
async enqueue(
key: string,
currentState: T,
optimisticState: T,
mutation: () => Promise<T>,
onStateChange: (state: T) => void
): Promise<void> {
// Cancel conflicting pending update for same key
if (this.pendingUpdates.has(key)) {
// Use the ORIGINAL previous state, not the optimistic one
const existing = this.pendingUpdates.get(key)!;
this.pendingUpdates.delete(key);
currentState = existing.previous;
}
onStateChange(optimisticState);
const mutationPromise = mutation();
this.pendingUpdates.set(key, {
previous: currentState,
optimistic: optimisticState,
mutation: mutationPromise,
});
try {
const serverState = await mutationPromise;
if (this.pendingUpdates.has(key)) {
onStateChange(serverState);
this.pendingUpdates.delete(key);
}
} catch {
if (this.pendingUpdates.has(key)) {
onStateChange(this.pendingUpdates.get(key)!.previous);
this.pendingUpdates.delete(key);
}
}
}
}Retroalimentación visual durante el estado pendiente
Los usuarios deben saber cuándo un dato es optimista y cuándo está confirmado. Señales visuales sutiles mantienen la confianza sin bloquear la interacción.
function CommentItem({
comment,
isPending,
}: {
comment: Comment;
isPending: boolean;
}) {
return (
<div
className={`comment ${isPending ? "comment--pending" : ""}`}
aria-busy={isPending}
>
<p>{comment.text}</p>
<span className="comment__author">{comment.authorName}</span>
{isPending && (
<span className="comment__status" aria-label="Saving comment">
Saving...
</span>
)}
</div>
);
}.comment--pending {
opacity: 0.7;
pointer-events: none;
position: relative;
}
.comment--pending::after {
content: "";
position: absolute;
inset: 0;
background: repeating-linear-gradient(
-45deg,
transparent,
transparent 4px,
rgba(0, 0, 0, 0.03) 4px,
rgba(0, 0, 0, 0.03) 8px
);
}Puntos clave
Las actualizaciones optimistas logran que las interfaces se sientan instantáneas al aplicar el estado esperado de inmediato y reconciliarlo después con el servidor. Guarda siempre el estado anterior antes de aplicar un cambio optimista, para poder revertirlo si algo falla. Usa IDs temporales para los elementos recién creados y reemplázalos por los IDs generados por el servidor cuando la operación tenga éxito.
Gestiona con cuidado las mutaciones concurrentes: cuando los usuarios actúan más rápido que la red, es clave mantener el estado de reversión correcto. Muestra una retroalimentación visual sutil (opacidad reducida, indicadores de "guardando...") para que los usuarios sepan que un dato está pendiente sin bloquear su interacción. El hook useOptimistic de React 19 simplifica este patrón para las Server Actions. La idea clave: los usuarios perciben tu aplicación como rápida cuando la interfaz responde a sus acciones de inmediato, aunque el servidor tarde 200 ms en confirmar.


