← Volver al listado de tecnologías

Capítulo 20: Frontend React para Tracking de Pedidos

Por: SiempreListo
sagareactfrontendwebsockettracking

Capítulo 20: Frontend React para Tracking de Pedidos

“Visibilidad en tiempo real del progreso de la saga”

Introduccion

Una saga puede tardar segundos o minutos en completarse. Durante ese tiempo, el usuario necesita feedback sobre el progreso de su pedido. Este capitulo implementa una interfaz React que muestra el estado de la saga en tiempo real.

Tecnologias que usaremos:

El resultado final sera una pagina de tracking que muestra cada paso de la saga actualizandose automaticamente mientras el backend procesa.

Arquitectura del Frontend

El diagrama muestra como el frontend se conecta al backend:

graph LR
    R[React App] --> WS[WebSocket]
    WS --> API[API Gateway]
    API --> S[Saga Orchestrator]
    R --> REST[REST API]
    REST --> API

Hook para WebSocket

Un hook en React es una funcion que encapsula logica reutilizable. useSagaTracking maneja la conexion WebSocket:

  1. useEffect: Se ejecuta cuando sagaId cambia, creando una nueva conexion
  2. WebSocket: Establece conexion bidireccional con el servidor
  3. Eventos: onopen, onmessage, onerror, onclose manejan el ciclo de vida
  4. Cleanup: La funcion de retorno cierra la conexion al desmontar el componente

El hook retorna tres valores: el estado actual de la saga, si estamos conectados, y cualquier error.

// hooks/useSagaTracking.ts
import { useEffect, useState, useCallback } from 'react';

interface SagaStep {
  name: string;
  status: 'pending' | 'running' | 'completed' | 'failed' | 'compensating';
  startedAt?: Date;
  completedAt?: Date;
  error?: string;
}

interface SagaState {
  sagaId: string;
  orderId?: string;
  status: 'running' | 'completed' | 'failed' | 'compensating';
  steps: SagaStep[];
  createdAt: Date;
}

export function useSagaTracking(sagaId: string | null) {
  const [state, setState] = useState<SagaState | null>(null);
  const [connected, setConnected] = useState(false);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    if (!sagaId) return;

    const ws = new WebSocket(`ws://localhost:3001/saga/${sagaId}`);

    ws.onopen = () => {
      setConnected(true);
      setError(null);
    };

    ws.onmessage = (event) => {
      const data = JSON.parse(event.data);
      setState(data);
    };

    ws.onerror = () => {
      setError('Connection error');
    };

    ws.onclose = () => {
      setConnected(false);
    };

    return () => ws.close();
  }, [sagaId]);

  return { state, connected, error };
}

Componente de Tracking

El componente OrderTracking visualiza el progreso de la saga:

Los iconos representan estados:

// components/OrderTracking.tsx
import { useSagaTracking } from '../hooks/useSagaTracking';

interface Props {
  sagaId: string;
}

const stepLabels: Record<string, string> = {
  CREATE_ORDER: 'Crear Pedido',
  RESERVE_STOCK: 'Reservar Stock',
  PROCESS_PAYMENT: 'Procesar Pago',
  SCHEDULE_SHIPPING: 'Programar Envío',
  NOTIFY_CUSTOMER: 'Notificar Cliente'
};

export function OrderTracking({ sagaId }: Props) {
  const { state, connected, error } = useSagaTracking(sagaId);

  if (error) {
    return <div className="text-red-500">Error: {error}</div>;
  }

  if (!state) {
    return <div className="animate-pulse">Cargando...</div>;
  }

  return (
    <div className="max-w-md mx-auto p-4">
      <div className="flex items-center justify-between mb-4">
        <h2 className="text-xl font-bold">Pedido #{state.orderId}</h2>
        <StatusBadge status={state.status} />
      </div>

      <div className="space-y-4">
        {state.steps.map((step, index) => (
          <StepItem key={step.name} step={step} index={index} />
        ))}
      </div>

      {!connected && (
        <div className="mt-4 text-sm text-yellow-600">
          Reconectando...
        </div>
      )}
    </div>
  );
}

function StatusBadge({ status }: { status: string }) {
  const colors: Record<string, string> = {
    running: 'bg-blue-100 text-blue-800',
    completed: 'bg-green-100 text-green-800',
    failed: 'bg-red-100 text-red-800',
    compensating: 'bg-yellow-100 text-yellow-800'
  };

  return (
    <span className={`px-2 py-1 rounded text-sm ${colors[status]}`}>
      {status}
    </span>
  );
}

function StepItem({ step, index }: { step: any; index: number }) {
  const icons: Record<string, string> = {
    pending: '○',
    running: '◐',
    completed: '●',
    failed: '✕',
    compensating: '↺'
  };

  return (
    <div className="flex items-center gap-3">
      <span className="text-xl">{icons[step.status]}</span>
      <div className="flex-1">
        <div className="font-medium">{stepLabels[step.name] || step.name}</div>
        {step.error && (
          <div className="text-sm text-red-500">{step.error}</div>
        )}
      </div>
      {step.status === 'running' && (
        <div className="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
      )}
    </div>
  );
}

Store con Zustand

Zustand es una biblioteca de gestion de estado minimalista para React. A diferencia de Redux, no requiere actions, reducers ni providers.

El store useOrderStore maneja:

Las funciones (addItem, submitOrder, etc.) modifican el estado. set() actualiza el store y React re-renderiza automaticamente los componentes que lo usan.

// stores/orderStore.ts
import { create } from 'zustand';

interface OrderItem {
  productId: string;
  name: string;
  quantity: number;
  price: number;
}

interface OrderState {
  items: OrderItem[];
  sagaId: string | null;
  status: string;
  addItem: (item: OrderItem) => void;
  removeItem: (productId: string) => void;
  submitOrder: () => Promise<void>;
  reset: () => void;
}

export const useOrderStore = create<OrderState>((set, get) => ({
  items: [],
  sagaId: null,
  status: 'idle',

  addItem: (item) => {
    set((state) => ({
      items: [...state.items, item]
    }));
  },

  removeItem: (productId) => {
    set((state) => ({
      items: state.items.filter((i) => i.productId !== productId)
    }));
  },

  submitOrder: async () => {
    set({ status: 'submitting' });

    const response = await fetch('/api/orders', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        items: get().items,
        total: get().items.reduce((sum, i) => sum + i.price * i.quantity, 0)
      })
    });

    const data = await response.json();
    set({ sagaId: data.sagaId, status: 'tracking' });
  },

  reset: () => {
    set({ items: [], sagaId: null, status: 'idle' });
  }
}));

Pagina de Checkout

La pagina de checkout orquesta todo el flujo del usuario:

  1. Estado idle: Muestra el carrito y boton de confirmar
  2. Estado submitting: Muestra “Procesando…” mientras espera respuesta del API
  3. Estado tracking: Muestra el componente OrderTracking con progreso en tiempo real

El flujo completo:

// pages/Checkout.tsx
import { useOrderStore } from '../stores/orderStore';
import { OrderTracking } from '../components/OrderTracking';

export function CheckoutPage() {
  const { items, sagaId, status, submitOrder, reset } = useOrderStore();

  const total = items.reduce((sum, i) => sum + i.price * i.quantity, 0);

  if (status === 'tracking' && sagaId) {
    return (
      <div className="p-6">
        <OrderTracking sagaId={sagaId} />
        <button
          onClick={reset}
          className="mt-4 px-4 py-2 bg-gray-200 rounded"
        >
          Nueva orden
        </button>
      </div>
    );
  }

  return (
    <div className="max-w-lg mx-auto p-6">
      <h1 className="text-2xl font-bold mb-4">Checkout</h1>

      <div className="space-y-2 mb-4">
        {items.map((item) => (
          <div key={item.productId} className="flex justify-between">
            <span>{item.name} x{item.quantity}</span>
            <span>${item.price * item.quantity}</span>
          </div>
        ))}
      </div>

      <div className="border-t pt-2 mb-4">
        <div className="flex justify-between font-bold">
          <span>Total</span>
          <span>${total.toFixed(2)}</span>
        </div>
      </div>

      <button
        onClick={submitOrder}
        disabled={status === 'submitting' || items.length === 0}
        className="w-full py-2 bg-blue-500 text-white rounded disabled:opacity-50"
      >
        {status === 'submitting' ? 'Procesando...' : 'Confirmar Pedido'}
      </button>
    </div>
  );
}

WebSocket Server (Backend)

El servidor WebSocket en el backend maneja las conexiones de clientes:

  1. WebSocketServer: Servidor que acepta conexiones WebSocket
  2. clients Map: Rastrea que clientes estan suscritos a cada saga
  3. notify(): Funcion que envia actualizaciones a todos los clientes interesados en una saga

Cuando el orquestador de saga actualiza el estado, llama a notify(sagaId, newState), y todos los clientes conectados reciben la actualizacion instantaneamente.

// server/websocket.ts
import { WebSocketServer, WebSocket } from 'ws';
import { SagaRepository } from '../saga/repository';

export function setupWebSocket(server: any, repository: SagaRepository) {
  const wss = new WebSocketServer({ server, path: '/saga' });
  const clients = new Map<string, Set<WebSocket>>();

  wss.on('connection', (ws, req) => {
    const sagaId = req.url?.split('/').pop();
    if (!sagaId) return ws.close();

    if (!clients.has(sagaId)) {
      clients.set(sagaId, new Set());
    }
    clients.get(sagaId)!.add(ws);

    // Enviar estado inicial
    repository.findById(sagaId).then((state) => {
      if (state) ws.send(JSON.stringify(state));
    });

    ws.on('close', () => {
      clients.get(sagaId)?.delete(ws);
    });
  });

  // Función para notificar cambios
  return {
    notify(sagaId: string, state: any) {
      const sagaClients = clients.get(sagaId);
      if (!sagaClients) return;

      const message = JSON.stringify(state);
      sagaClients.forEach((client) => {
        if (client.readyState === WebSocket.OPEN) {
          client.send(message);
        }
      });
    }
  };
}

Resumen

Glosario

React Hook

Definicion: Funcion especial de React (prefijo use) que permite usar estado y otras caracteristicas de React en componentes funcionales.

Por que es importante: Permite encapsular y reutilizar logica compleja (como conexiones WebSocket) de forma limpia y declarativa.

Ejemplo practico: useSagaTracking(sagaId) encapsula toda la logica de WebSocket. El componente solo necesita llamar al hook y usar los valores que retorna.


WebSocket

Definicion: Protocolo de comunicacion que proporciona canales de comunicacion bidireccional full-duplex sobre una conexion TCP persistente.

Por que es importante: Permite push de datos del servidor al cliente sin que este tenga que hacer polling, ideal para actualizaciones en tiempo real.

Ejemplo practico: Cuando el paso “Procesar Pago” se completa en el backend, el servidor envia un mensaje WebSocket y el UI se actualiza instantaneamente.


Zustand

Definicion: Biblioteca de gestion de estado para React que ofrece una API simple basada en hooks, sin el boilerplate de Redux.

Por que es importante: Simplifica la gestion de estado compartido entre componentes, con excelente rendimiento y tipado TypeScript.

Ejemplo practico: const { items, addItem } = useOrderStore() accede al carrito desde cualquier componente sin pasar props.


Renderizado Condicional

Definicion: Tecnica en React donde diferentes elementos se renderizan segun condiciones, usando operadores ternarios, && o early returns.

Por que es importante: Permite mostrar diferentes UIs segun el estado de la aplicacion (loading, error, exito) de forma declarativa.

Ejemplo practico: if (error) return <Error />; if (!state) return <Loading />; return <Content /> muestra la UI apropiada segun el estado.


useEffect

Definicion: Hook de React que ejecuta efectos secundarios (suscripciones, llamadas API, manipulacion DOM) despues del renderizado.

Por que es importante: Permite sincronizar el componente con sistemas externos como WebSockets, APIs o el DOM.

Ejemplo practico: El efecto crea la conexion WebSocket cuando el componente se monta y la cierra cuando se desmonta, evitando memory leaks.


Cleanup Function

Definicion: Funcion retornada por useEffect que React ejecuta antes de re-ejecutar el efecto o cuando el componente se desmonta.

Por que es importante: Previene memory leaks y comportamientos inesperados al limpiar recursos como conexiones, timers o suscripciones.

Ejemplo practico: return () => ws.close() cierra la conexion WebSocket cuando el usuario navega a otra pagina, liberando recursos.


Estado de Conexion

Definicion: Variable que rastrea si una conexion (WebSocket, API) esta activa, permitiendo mostrar indicadores visuales al usuario.

Por que es importante: Proporciona feedback al usuario sobre problemas de conectividad, mejorando la experiencia en redes inestables.

Ejemplo practico: Si connected === false, mostramos “Reconectando…” para que el usuario sepa que las actualizaciones pueden estar retrasadas.


Push vs Pull

Definicion: Dos modelos de comunicacion: push (el servidor envia datos cuando hay cambios) vs pull (el cliente pregunta periodicamente).

Por que es importante: Push (WebSocket) es mas eficiente y proporciona actualizaciones inmediatas; pull (polling) es mas simple pero genera trafico innecesario.

Ejemplo practico: En lugar de consultar cada 2 segundos “¿cambio el estado?”, el servidor nos avisa instantaneamente cuando hay un cambio.


← Capítulo 19: Kafka | Capítulo 21: Testing de Sagas →