useOptimistic: mostrar retroalimentación instantánea de la interfaz de usuario mientras se completan acciones asincrónicas
Receta
"use client";
import { useOptimistic } from "react";
type Message = { id: string; text: string; sending?: boolean };
function Chat({
messages,
sendMessage,
}: {
messages: Message[];
sendMessage: (text: string) => Promise<void>;
}) {
const [optimisticMessages, addOptimistic] = useOptimistic(
messages,
(state, newText: string) => [
...state,
{ id: "temp-" + Date.now(), text: newText, sending: true },
]
);
async function handleSubmit(formData: FormData) {
const text = formData.get("text") as string;
addOptimistic(text);
await sendMessage(text);
}
return (
<div>
<ul>
{optimisticMessages.map((msg) => (
<li key={msg.id} className={msg.sending ? "opacity-50" : ""}>
{msg.text}
{msg.sending && " (enviando...)"}
</li>
))}
</ul>
<form action={handleSubmit}>
<input name="text" required />
<button type="submit">Enviar</button>
</form>
</div>
);
}Cuándo usarlo: Usa useOptimistic siempre que quieras que la interfaz de usuario se actualice instantáneamente mientras una operación asincrónica (acción de servidor, llamada a API) está en tránsito —me gusta, mensajes, alternancias, actualizaciones de carrito, cualquier mutación donde el usuario no debería esperar.
Ejemplo Funcional
// Una lista de tareas con adición, alternancia y eliminación optimista
"use client";
import { useOptimistic, useActionState, useRef } from "react";
type Todo = {
id: string;
text: string;
completed: boolean;
pending?: boolean;
deleting?: boolean;
};
// Simular acciones de servidor
async function serverAddTodo(text: string): Promise<Todo> {
await new Promise((r) => setTimeout(r, 1000));
return { id: crypto.randomUUID(), text, completed: false };
}
async function serverToggleTodo(id: string): Promise<void> {
await new Promise((r) => setTimeout(r, 500));
}
async function serverDeleteTodo(id: string): Promise<void> {
await new Promise((r) => setTimeout(r, 500));
}
type OptimisticAction =
| { type: "add"; text: string }
| { type: "toggle"; id: string }
| { type: "delete"; id: string };
export default function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
const [todos, setTodos] = useActionState(
async (_prev: Todo[], formData: FormData) => {
const text = formData.get("text") as string;
addOptimistic({ type: "add", text });
const newTodo = await serverAddTodo(text);
return [..._prev, newTodo];
},
initialTodos
);
const [optimisticTodos, addOptimistic] = useOptimistic(
todos,
(state: Todo[], action: OptimisticAction) => {
switch (action.type) {
case "add":
return [...state, { id: "temp", text: action.text, completed: false, pending: true }];
case "toggle":
return state.map((t) =>
t.id === action.id ? { ...t, completed: !t.completed, pending: true } : t
);
case "delete":
return state.map((t) =>
t.id === action.id ? { ...t, deleting: true } : t
);
}
}
);
const formRef = useRef<HTMLFormElement>(null);
async function handleToggle(id: string) {
addOptimistic({ type: "toggle", id });
await serverToggleTodo(id);
}
async function handleDelete(id: string) {
addOptimistic({ type: "delete", id });
await serverDeleteTodo(id);
}
return (
<div className="max-w-md mx-auto">
<h1 className="text-2xl font-bold mb-4">Tareas</h1>
<ul className="space-y-2">
{optimisticTodos
.filter((t) => !t.deleting)
.map((todo) => (
<li
key={todo.id}
className={`flex items-center gap-2 ${todo.pending ? "opacity-50" : ""}`}
>
<input
type="checkbox"
checked={todo.completed}
onChange={() => handleToggle(todo.id)}
/>
<span className={todo.completed ? "line-through" : ""}>{todo.text}</span>
<button onClick={() => handleDelete(todo.id)} className="ml-auto text-red-500">
Eliminar
</button>
</li>
))}
</ul>
<form ref={formRef} action={async (formData) => {
const text = formData.get("text") as string;
addOptimistic({ type: "add", text });
formRef.current?.reset();
const newTodo = await serverAddTodo(text);
// En una aplicación real, la revalidación actualizaría las tareas
}}>
<div className="flex gap-2 mt-4">
<input name="text" required className="border p-2 rounded flex-1" />
<button type="submit" className="bg-blue-500 text-white px-4 rounded">Agregar</button>
</div>
</form>
</div>
);
}Lo que demuestra esto:
- Una sola llamada a
useOptimisticque maneja tres tipos de acciones diferentes (agregar, alternar, eliminar) - Elementos optimistas estilizados de manera diferente (opacidad, tachado) para indicar estado pendiente
- Elementos eliminados ocultados inmediatamente a través de la bandera
deleting - El estado optimista se revierte automáticamente al valor real de
todoscuando la acción se completa
Profundización
Cómo Funciona
useOptimistic(passthrough, updateFn)devuelve[optimisticState, addOptimistic].passthroughes la fuente de datos real (por ejemplo, de props ouseActionState). Cuando no hay acción en tránsito,optimisticState === passthrough.updateFn(currentState, optimisticValue)es una función pura que produce la versión optimista del estado.addOptimistic(value)activaupdateFninmediatamente, haciendo que la interfaz de usuario se actualice antes de que el trabajo asincrónico se complete.
- Cuando la acción asincrónica (acción de formulario, transición, acción de servidor) se resuelve, React reemplaza el estado optimista con el valor
passthroughactualizado. No hay ningún paso manual de "confirmar" o "retroceder". - Si la acción falla, el estado optimista se revierte al valor original de
passthrough. El usuario ve que el cambio se "deshace" a sí mismo. useOptimisticestá diseñado para funcionar con el sistema de transición y acción de React. Llamar aaddOptimisticfuera de una acción o transición no tiene efecto.- Múltiples llamadas a
addOptimisticdurante la misma acción se agrupan por lotes.updateFnrecibe el estado optimista acumulado.
Variaciones
Simple alternancia de booleano:
function LikeButton({ isLiked, onToggle }: { isLiked: boolean; onToggle: () => Promise<void> }) {
const [optimisticLiked, setOptimisticLiked] = useOptimistic(isLiked);
return (
<form action={async () => {
setOptimisticLiked(!optimisticLiked);
await onToggle();
}}>
<button type="submit">{optimisticLiked ? "No me gusta" : "Me gusta"}</button>
</form>
);
}Con useActionState para estado de formulario combinado e interfaz de usuario optimista:
"use client";
import { useActionState, useOptimistic } from "react";
import { addToCart } from "./actions";
function CartButton({ count }: { count: number }) {
const [serverCount, action, isPending] = useActionState(addToCart, count);
const [optimisticCount, setOptimisticCount] = useOptimistic(serverCount);
return (
<form action={async (formData) => {
setOptimisticCount((c) => c + 1);
await action(formData);
}}>
<button type="submit">Agregar al carrito ({optimisticCount})</button>
</form>
);
}Notas de TypeScript
useOptimistic<State, Action>(passthrough: State, updateFn: (state: State, action: Action) => State)devuelve[State, (action: Action) => void].- Cuando no se proporciona
updateFn, el segundo argumento aaddOptimisticreemplaza el estado directamente:useOptimistic<State>(passthrough: State)devuelve[State, (newState: State) => void]. - El parámetro de tipo
Actioncontrola qué pasas aaddOptimistic. Usa una unión discriminada para múltiples tipos de acciones.
Errores Comunes
- Llamar a addOptimistic fuera de una acción o transición -- La actualización optimista no se aplicará o se revertirá inmediatamente. Solución: Siempre llama a
addOptimisticdentro de una acción de formulario, acción de servidor o devolución de llamadastartTransition. - Mutar estado en updateFn --
updateFndebe ser puro. Mutar la matriz/objeto de estado actual causa errores. Solución: Siempre devuelve una nueva matriz/objeto:[...state, newItem]. - El estado optimista no persiste después de que la acción se completa -- El valor de paso reemplaza el estado optimista cuando la acción se completa. Si el servidor devuelve datos diferentes a los esperados, la interfaz de usuario "saltará". Solución: Asegúrate de que tu predicción optimista coincida con lo que el servidor devolverá.
- El retroceso por error es automático pero silencioso -- Cuando una acción falla, el estado optimista se revierte sin notificación. Solución: Combina con el manejo de errores de
useActionStatepara mostrar un mensaje de error. - Múltiples presentaciones rápidas -- Cada acción se pone en cola; las actualizaciones optimistas se apilan una encima de la otra. Este es un comportamiento correcto pero puede parecer extraño si las actualizaciones entran en conflicto. Solución: Diseña tu
updateFnpara manejar correctamente el estado acumulado.
Alternativas
| Enfoque | Cuándo elegir |
|---|---|
useOptimistic | React 19 integrado, funciona con acciones de formulario y transiciones |
TanStack Query useMutation con onMutate | Necesitas almacenamiento en caché, reintentos y retroceso sofisticado |
SWR mutate con optimisticData | Ya estás usando SWR para obtención de datos |
useState manual | Casos simples donde tú mismo administras el estado pendiente |
| Redux Toolkit actualizaciones optimistas | Aplicación Redux grande con middleware existente |
Preguntas Frecuentes
¿Qué devuelve useOptimistic y cómo funciona?
- Devuelve
[optimisticState, addOptimistic] optimisticStatees igual al valorpassthroughcuando no hay acción en tránsito- Llamar a
addOptimistic(value)activaupdateFninmediatamente para producir una versión optimista del estado - Cuando la acción asincrónica se resuelve, React reemplaza el estado optimista con el valor
passthroughactualizado
¿Qué sucede si la acción asincrónica falla después de una actualización optimista?
- El estado optimista se revierte automáticamente al valor original de
passthrough - No se necesita ningún paso manual de "confirmar" o "retroceder"
- Combina con el manejo de errores de
useActionStatepara mostrar un mensaje de error, ya que el retroceso es silencioso
¿Puedes usar useOptimistic sin una acción de formulario?
addOptimisticdebe ser llamado dentro de una acción de formulario, acción de servidor o devolución de llamadastartTransition- Llamarlo fuera de una acción o transición no tiene efecto o se revierte inmediatamente
- Envuelve tu lógica asincrónica en
startTransitionsi no estás usando un formulario
¿Cómo manejas múltiples tipos de acciones (agregar, alternar, eliminar) con un único useOptimistic?
Usa una unión discriminada para el tipo de acción:
type Action =
| { type: "add"; text: string }
| { type: "toggle"; id: string }
| { type: "delete"; id: string };
const [optimistic, dispatch] = useOptimistic(
todos,
(state, action: Action) => {
switch (action.type) {
case "add": return [...state, { id: "temp", text: action.text }];
case "toggle": return state.map(t => t.id === action.id ? { ...t, completed: !t.completed } : t);
case "delete": return state.filter(t => t.id !== action.id);
}
}
);¿Cómo usas useOptimistic para una simple alternancia de booleano como un botón Me gusta?
function LikeButton({ isLiked, onToggle }) {
const [optimisticLiked, setOptimisticLiked] = useOptimistic(isLiked);
return (
<form action={async () => {
setOptimisticLiked(!optimisticLiked);
await onToggle();
}}>
<button type="submit">{optimisticLiked ? "No me gusta" : "Me gusta"}</button>
</form>
);
}Cuando no se proporciona updateFn, addOptimistic reemplaza el estado directamente.
¿Cómo distingues visualmente elementos optimistas de elementos confirmados?
- Agrega una bandera como
pending: trueosending: trueen el valor de retorno deupdateFn - Estiliza elementos optimistas de manera diferente (por ejemplo,
opacity-50, texto en cursiva, etiqueta "(enviando...)") - La bandera desaparece cuando los datos reales reemplazan el estado optimista
¿Pueden apilar múltiples llamadas a addOptimistic durante la misma acción?
- Sí. Múltiples llamadas durante la misma acción se agrupan por lotes
updateFnrecibe el estado optimista acumulado de llamadas anteriores- Diseña tu
updateFnpara manejar correctamente el estado acumulado y evitar conflictos
Error común: ¿Por qué la interfaz de usuario "salta" cuando el servidor devuelve datos después de una actualización optimista?
- El valor
passthroughreemplaza el estado optimista cuando la acción se completa - Si el servidor devuelve datos diferentes a tu predicción optimista, la interfaz de usuario mostrará un cambio visible
- Asegúrate de que tu predicción optimista coincida estrechamente con lo que el servidor devolverá
Error común: ¿Puedes mutar la matriz de estado en updateFn?
- No.
updateFndebe ser puro -- mutar el estado actual causa errores - Siempre devuelve una nueva matriz u objeto:
[...state, newItem]en lugar destate.push(newItem) - Esto sigue las mismas reglas de inmutabilidad que las actualizaciones de estado de React
¿Cómo tipas useOptimistic con un tipo de acción personalizado en TypeScript?
useOptimistic<State, Action>(
passthrough: State,
updateFn: (state: State, action: Action) => State
): [State, (action: Action) => void]Usa una unión discriminada para el tipo Action para admitir múltiples tipos de acciones.
¿Cuál es la firma de TypeScript cuando useOptimistic se usa sin un updateFn?
useOptimistic<State>(passthrough: State)devuelve[State, (newState: State) => void]- El segundo argumento a
addOptimisticreemplaza el estado directamente - Esta es la forma más simple para alternancias de un solo valor o contadores
Relacionado
- Acciones de Formulario -- La forma principal de desencadenar actualizaciones optimistas
- Acciones de Servidor -- Funciones de servidor que se emparejan con interfaz de usuario optimista
- Hook use() -- Lectura de datos asincrónica que alimentan el estado optimista
- Descripción General -- Lista completa de características de React 19