← Volver al listado de tecnologías

Capítulo 20: Optimistic UI y Sincronización

Por: SiempreListo
event-sourcingoptimistic-uireactux

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:

  1. Enviar comando
  2. Evento procesado
  3. Proyección actualizada
  4. 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:

// 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

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.


← Capítulo 19: Frontend React | Capítulo 21: Testing →