Capítulo 20: Optimistic UI y Sincronización
Capítulo 20: Optimistic UI y Sincronización
“Actualiza la UI inmediatamente, reconcilia después”
Introducción a Optimistic UI
Optimistic UI es una técnica donde la interfaz se actualiza inmediatamente asumiendo que la operación tendrá éxito, y luego se reconcilia con el resultado real del servidor.
Esta técnica mejora dramáticamente la percepción de velocidad: el usuario ve el resultado al instante en lugar de esperar la respuesta del servidor.
El Reto de la Consistencia Eventual
En Event Sourcing con CQRS, hay un delay entre:
- Enviar comando
- Evento procesado
- Proyección actualizada
- Read model disponible
Optimistic UI aborda este problema actualizando la interfaz antes de que el servidor confirme, proporcionando feedback instantáneo al usuario.
Implementación con TanStack Query
TanStack Query proporciona callbacks específicos para implementar Optimistic UI:
- onMutate: Se ejecuta antes de la mutación; actualiza cache optimistamente
- onError: Se ejecuta si falla; revierte al estado anterior
- onSettled: Se ejecuta siempre al final; sincroniza con el servidor
// src/hooks/useOptimisticOrder.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ordersApi } from '../api/orders';
import type { Order } from '../api/types';
export function useConfirmOrderOptimistic() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => ordersApi.confirm(id),
// Actualización optimista
onMutate: async (id) => {
// Cancelar queries en vuelo
await queryClient.cancelQueries({ queryKey: ['orders', id] });
// Snapshot del estado anterior
const previousOrder = queryClient.getQueryData<Order>(['orders', id]);
// Actualizar optimistamente
queryClient.setQueryData<Order>(['orders', id], (old) => {
if (!old) return old;
return {
...old,
status: 'confirmed',
updatedAt: new Date().toISOString()
};
});
// También actualizar la lista
queryClient.setQueryData<Order[]>(['orders'], (old) => {
if (!old) return old;
return old.map(order =>
order.id === id
? { ...order, status: 'confirmed' }
: order
);
});
return { previousOrder };
},
// Revertir en caso de error
onError: (err, id, context) => {
if (context?.previousOrder) {
queryClient.setQueryData(['orders', id], context.previousOrder);
}
// Refrescar lista
queryClient.invalidateQueries({ queryKey: ['orders'] });
},
// Sincronizar con servidor después de éxito
onSettled: (_, __, id) => {
queryClient.invalidateQueries({ queryKey: ['orders', id] });
}
});
}
export function useAddItemOptimistic() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ orderId, item }: {
orderId: string;
item: { productId: string; quantity: number; price: number }
}) => ordersApi.addItem(orderId, item),
onMutate: async ({ orderId, item }) => {
await queryClient.cancelQueries({ queryKey: ['orders', orderId] });
const previousOrder = queryClient.getQueryData<Order>(['orders', orderId]);
// Agregar item optimistamente
queryClient.setQueryData<Order>(['orders', orderId], (old) => {
if (!old) return old;
const newItem = {
productId: item.productId,
productName: 'Loading...', // Se actualizará
quantity: item.quantity,
unitPrice: item.price
};
const newTotal = old.total + (item.price * item.quantity);
return {
...old,
items: [...old.items, newItem],
total: newTotal
};
});
return { previousOrder };
},
onError: (err, { orderId }, context) => {
if (context?.previousOrder) {
queryClient.setQueryData(['orders', orderId], context.previousOrder);
}
},
onSettled: (_, __, { orderId }) => {
queryClient.invalidateQueries({ queryKey: ['orders', orderId] });
}
});
}
Componente con Estado Optimista
// src/components/OrderActions.tsx
import { useState } from 'react';
import { useConfirmOrderOptimistic } from '../hooks/useOptimisticOrder';
export function OrderActions({ order }: { order: Order }) {
const confirmOrder = useConfirmOrderOptimistic();
const [isConfirming, setIsConfirming] = useState(false);
const handleConfirm = async () => {
setIsConfirming(true);
try {
await confirmOrder.mutateAsync(order.id);
} catch (error) {
// El error ya fue manejado por onError
console.error('Failed to confirm order');
} finally {
setIsConfirming(false);
}
};
return (
<div className="flex gap-2">
{order.status === 'draft' && (
<button
onClick={handleConfirm}
disabled={isConfirming || confirmOrder.isPending}
className={`
px-4 py-2 rounded
${confirmOrder.isPending
? 'bg-blue-300 cursor-wait'
: 'bg-blue-600 hover:bg-blue-700'
}
text-white
`}
>
{confirmOrder.isPending ? (
<span className="flex items-center gap-2">
<Spinner /> Confirmando...
</span>
) : (
'Confirmar Pedido'
)}
</button>
)}
</div>
);
}
function Spinner() {
return (
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12" cy="12" r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
);
}
Indicador de Sincronización
// src/components/SyncIndicator.tsx
import { useIsFetching, useIsMutating } from '@tanstack/react-query';
export function SyncIndicator() {
const isFetching = useIsFetching();
const isMutating = useIsMutating();
if (!isFetching && !isMutating) return null;
return (
<div className="fixed bottom-4 right-4 flex items-center gap-2 bg-white shadow-lg rounded-full px-4 py-2">
<div className="animate-spin h-4 w-4 border-2 border-blue-600 border-t-transparent rounded-full" />
<span className="text-sm text-gray-600">
{isMutating ? 'Guardando...' : 'Sincronizando...'}
</span>
</div>
);
}
Polling para Consistencia Eventual
Cuando no podemos usar WebSockets, el polling verifica periódicamente si el cambio ya se propagó a los read models. Dejamos de hacer polling cuando el estado esperado coincide:
// src/hooks/useOrderWithPolling.ts
import { useQuery } from '@tanstack/react-query';
import { ordersApi } from '../api/orders';
export function useOrderWithPolling(id: string, expectedStatus?: string) {
return useQuery({
queryKey: ['orders', id],
queryFn: () => ordersApi.getById(id),
// Polling cada 2 segundos si esperamos un cambio
refetchInterval: (query) => {
if (!expectedStatus) return false;
const data = query.state.data;
// Seguir polling hasta que el status coincida
if (data && data.status !== expectedStatus) {
return 2000;
}
return false;
}
});
}
// Uso
function OrderDetail({ id }: { id: string }) {
const [expectedStatus, setExpectedStatus] = useState<string>();
const { data: order } = useOrderWithPolling(id, expectedStatus);
const confirmOrder = useConfirmOrderOptimistic();
const handleConfirm = async () => {
setExpectedStatus('confirmed');
await confirmOrder.mutateAsync(id);
// El polling verificará que el cambio se propagó
};
// Limpiar expected status cuando coincida
useEffect(() => {
if (order?.status === expectedStatus) {
setExpectedStatus(undefined);
}
}, [order?.status, expectedStatus]);
// ...
}
WebSocket para Updates en Tiempo Real
Los WebSockets proporcionan notificaciones push del servidor, eliminando la necesidad de polling. El servidor notifica cuando hay cambios relevantes:
// src/hooks/useOrderSubscription.ts
import { useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
export function useOrderSubscription(orderId: string) {
const queryClient = useQueryClient();
useEffect(() => {
const ws = new WebSocket(
`${import.meta.env.VITE_WS_URL}/orders/${orderId}`
);
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'ORDER_UPDATED') {
// Invalidar cache para refrescar
queryClient.invalidateQueries({ queryKey: ['orders', orderId] });
}
if (data.type === 'ORDER_EVENT') {
// Agregar evento al timeline
queryClient.setQueryData<OrderEvent[]>(
['orders', orderId, 'events'],
(old) => old ? [...old, data.event] : [data.event]
);
}
};
return () => ws.close();
}, [orderId, queryClient]);
}
Manejo de Conflictos
En sistemas con múltiples usuarios, dos personas pueden intentar modificar el mismo recurso simultáneamente. El Conflict Resolver presenta ambas versiones al usuario para que decida:
// src/components/ConflictResolver.tsx
import { useState } from 'react';
interface ConflictState {
localVersion: Order;
serverVersion: Order;
}
export function ConflictResolver({
conflict,
onResolve
}: {
conflict: ConflictState;
onResolve: (choice: 'local' | 'server') => void;
}) {
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center">
<div className="bg-white rounded-lg p-6 max-w-2xl w-full">
<h3 className="text-lg font-bold mb-4">Conflicto Detectado</h3>
<p className="text-gray-600 mb-4">
El pedido fue modificado mientras editabas. ¿Qué versión quieres mantener?
</p>
<div className="grid grid-cols-2 gap-4 mb-6">
<div className="border rounded p-4">
<h4 className="font-medium mb-2">Tu versión</h4>
<pre className="text-sm bg-gray-50 p-2 rounded">
{JSON.stringify(conflict.localVersion, null, 2)}
</pre>
</div>
<div className="border rounded p-4">
<h4 className="font-medium mb-2">Versión del servidor</h4>
<pre className="text-sm bg-gray-50 p-2 rounded">
{JSON.stringify(conflict.serverVersion, null, 2)}
</pre>
</div>
</div>
<div className="flex gap-4 justify-end">
<button
onClick={() => onResolve('server')}
className="px-4 py-2 border rounded hover:bg-gray-50"
>
Usar versión del servidor
</button>
<button
onClick={() => onResolve('local')}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Mantener mis cambios
</button>
</div>
</div>
</div>
);
}
Resumen
- Optimistic UI mejora la experiencia del usuario
- TanStack Query facilita rollback en caso de error
- El polling verifica consistencia eventual
- WebSockets permiten updates en tiempo real
- Los conflictos deben manejarse explícitamente
Glosario
Optimistic UI (UI Optimista)
Definición: Técnica donde la interfaz refleja el resultado esperado de una operación inmediatamente, antes de recibir confirmación del servidor.
Por qué es importante: Elimina la percepción de latencia; el usuario ve feedback instantáneo en lugar de esperar roundtrips de red.
Ejemplo práctico: Al hacer clic en “Confirmar Orden”, el status cambia a “Confirmada” al instante; si el servidor rechaza, se revierte con mensaje de error.
Rollback (Reversión)
Definición: Proceso de revertir la UI al estado anterior cuando una operación optimista falla.
Por qué es importante: Sin rollback, la UI quedaría en un estado inconsistente con el servidor después de un error.
Ejemplo práctico: onMutate guarda previousOrder; onError restaura queryClient.setQueryData(['orders', id], previousOrder).
Consistencia Eventual
Definición: Modelo de consistencia donde las actualizaciones se propagan gradualmente y todos los nodos eventualmente convergen al mismo estado.
Por qué es importante: En Event Sourcing con CQRS, el read model no se actualiza instantáneamente después de escribir un evento.
Ejemplo práctico: Confirmar orden escribe evento a las 10:00:00.000; la proyección lo procesa a las 10:00:00.050; el read model refleja el cambio a las 10:00:00.100.
Polling
Definición: Técnica donde el cliente consulta al servidor repetidamente a intervalos regulares para detectar cambios.
Por qué es importante: Alternativa simple a WebSockets cuando no están disponibles o para verificar propagación de cambios.
Ejemplo práctico: refetchInterval: 2000 consulta cada 2 segundos hasta que order.status === expectedStatus, luego detiene el polling.
WebSocket
Definición: Protocolo de comunicación bidireccional persistente entre cliente y servidor sobre una conexión TCP.
Por qué es importante: Permite que el servidor envíe actualizaciones al cliente sin que este las solicite (push vs poll).
Ejemplo práctico: Conexión WebSocket a /orders/123; cuando otro usuario modifica la orden, el servidor envía notificación y el cliente actualiza la UI.
Conflict Resolution (Resolución de Conflictos)
Definición: Proceso de decidir qué hacer cuando dos cambios concurrentes afectan el mismo recurso.
Por qué es importante: Sin manejo de conflictos, el último en escribir gana y el trabajo del primer usuario se pierde silenciosamente.
Ejemplo práctico: Usuario A y B editan la misma orden; cuando B guarda, se detecta que A ya modificó; se muestra diálogo para elegir versión.
Previous State Snapshot
Definición: Copia del estado antes de una operación optimista, usada para rollback si la operación falla.
Por qué es importante: Sin snapshot, no podríamos saber a qué estado revertir después de un error.
Ejemplo práctico: const previousOrder = queryClient.getQueryData(['orders', id]) guarda el estado; onError lo restaura.
invalidateQueries
Definición: Función de TanStack Query que marca queries como obsoletas, triggereando refetch en segundo plano.
Por qué es importante: Después de una mutación exitosa, invalida datos relacionados para que se actualicen con el estado real del servidor.
Ejemplo práctico: onSettled: () => invalidateQueries(['orders', id]) asegura que después de confirmar (éxito o error), se sincronice con el servidor.