← Volver al listado de tecnologías

Capítulo 19: Frontend React con Event Sourcing

Por: SiempreListo
event-sourcingreactfrontendtanstack-query

Capítulo 19: Frontend React con Event Sourcing

“El frontend consume read models y emite comandos”

El Rol del Frontend en Event Sourcing

En un sistema event-sourced, el frontend tiene dos responsabilidades principales:

  1. Leer proyecciones (Read Models): Datos optimizados para visualización, actualizados por proyecciones del backend
  2. Enviar comandos: Expresiones de intención que el backend valida y convierte en eventos

El frontend nunca escribe eventos directamente; solo envía comandos al backend que decide si son válidos.

Arquitectura Frontend

graph LR
    UI[React UI] --> Q[Queries]
    UI --> C[Commands]
    Q --> RM[Read Models API]
    C --> CMD[Commands API]
    CMD --> ES[Event Store]
    ES --> P[Proyecciones]
    P --> RM

Setup del Proyecto

# Crear proyecto con Vite (bundler moderno y rápido)
bun create vite orderflow-frontend --template react-ts
cd orderflow-frontend

# Instalar dependencias principales
# @tanstack/react-query: manejo de estado del servidor con cache
# axios: cliente HTTP con interceptores
# zod: validación de schemas en runtime
bun add @tanstack/react-query axios zod
bun add -d @types/react @types/react-dom

Estructura del Proyecto

src/
├── api/
│   ├── client.ts
│   ├── orders.ts
│   └── types.ts
├── components/
│   ├── OrderList.tsx
│   ├── OrderDetail.tsx
│   ├── CreateOrderForm.tsx
│   └── OrderTimeline.tsx
├── hooks/
│   ├── useOrders.ts
│   └── useOrderCommands.ts
├── stores/
│   └── orderStore.ts
└── App.tsx

Cliente API

El cliente API centraliza la comunicación con el backend. Usamos Zod para validar que las respuestas del servidor coinciden con nuestros tipos, detectando desincronizaciones entre frontend y backend en desarrollo.

// src/api/client.ts
import axios from 'axios';

export const api = axios.create({
  baseURL: import.meta.env.VITE_API_URL ?? 'http://localhost:3000',
  headers: {
    'Content-Type': 'application/json'
  }
});

// src/api/types.ts
import { z } from 'zod';

export const OrderItemSchema = z.object({
  productId: z.string(),
  productName: z.string(),
  quantity: z.number(),
  unitPrice: z.number()
});

export const OrderSchema = z.object({
  id: z.string(),
  customerId: z.string(),
  status: z.enum(['draft', 'confirmed', 'paid', 'shipped', 'delivered', 'cancelled']),
  items: z.array(OrderItemSchema),
  total: z.number(),
  createdAt: z.string(),
  updatedAt: z.string()
});

export const OrderEventSchema = z.object({
  eventId: z.string(),
  eventType: z.string(),
  occurredAt: z.string(),
  data: z.record(z.unknown())
});

export type Order = z.infer<typeof OrderSchema>;
export type OrderItem = z.infer<typeof OrderItemSchema>;
export type OrderEvent = z.infer<typeof OrderEventSchema>;

// src/api/orders.ts
import { api } from './client';
import { Order, OrderSchema, OrderEvent, OrderEventSchema } from './types';

export const ordersApi = {
  async getAll(): Promise<Order[]> {
    const { data } = await api.get('/orders');
    return z.array(OrderSchema).parse(data);
  },

  async getById(id: string): Promise<Order> {
    const { data } = await api.get(`/orders/${id}`);
    return OrderSchema.parse(data);
  },

  async getEvents(id: string): Promise<OrderEvent[]> {
    const { data } = await api.get(`/orders/${id}/events`);
    return z.array(OrderEventSchema).parse(data);
  },

  async create(command: CreateOrderCommand): Promise<{ orderId: string }> {
    const { data } = await api.post('/orders', command);
    return data;
  },

  async confirm(id: string): Promise<void> {
    await api.post(`/orders/${id}/confirm`);
  },

  async cancel(id: string, reason: string): Promise<void> {
    await api.post(`/orders/${id}/cancel`, { reason });
  }
};

interface CreateOrderCommand {
  customerId: string;
  items: Array<{
    productId: string;
    quantity: number;
  }>;
  shippingAddress: {
    street: string;
    city: string;
    state: string;
    zipCode: string;
    country: string;
  };
}

Hooks con TanStack Query

TanStack Query (antes React Query) maneja todo el estado del servidor: fetching, caching, sincronización y actualizaciones. Los conceptos clave son:

// src/hooks/useOrders.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { ordersApi } from '../api/orders';

export function useOrders() {
  return useQuery({
    queryKey: ['orders'],
    queryFn: ordersApi.getAll,
    staleTime: 30_000
  });
}

export function useOrder(id: string) {
  return useQuery({
    queryKey: ['orders', id],
    queryFn: () => ordersApi.getById(id),
    enabled: !!id
  });
}

export function useOrderEvents(id: string) {
  return useQuery({
    queryKey: ['orders', id, 'events'],
    queryFn: () => ordersApi.getEvents(id),
    enabled: !!id
  });
}

// src/hooks/useOrderCommands.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ordersApi } from '../api/orders';

export function useCreateOrder() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: ordersApi.create,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['orders'] });
    }
  });
}

export function useConfirmOrder() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (id: string) => ordersApi.confirm(id),
    onSuccess: (_, id) => {
      queryClient.invalidateQueries({ queryKey: ['orders', id] });
      queryClient.invalidateQueries({ queryKey: ['orders'] });
    }
  });
}

export function useCancelOrder() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: ({ id, reason }: { id: string; reason: string }) =>
      ordersApi.cancel(id, reason),
    onSuccess: (_, { id }) => {
      queryClient.invalidateQueries({ queryKey: ['orders', id] });
      queryClient.invalidateQueries({ queryKey: ['orders'] });
    }
  });
}

Componentes

Los componentes React consumen los hooks y renderizan la UI. El patrón es:

  1. Llamar al hook para obtener datos/mutaciones
  2. Manejar estados de carga y error
  3. Renderizar datos cuando están disponibles
// src/components/OrderList.tsx
import { useOrders } from '../hooks/useOrders';
import { Link } from 'react-router-dom';

export function OrderList() {
  const { data: orders, isLoading, error } = useOrders();

  if (isLoading) return <div>Cargando pedidos...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <div className="space-y-4">
      <h2 className="text-2xl font-bold">Pedidos</h2>
      <div className="grid gap-4">
        {orders?.map(order => (
          <Link
            key={order.id}
            to={`/orders/${order.id}`}
            className="p-4 border rounded hover:bg-gray-50"
          >
            <div className="flex justify-between">
              <span className="font-mono">{order.id.slice(0, 8)}...</span>
              <StatusBadge status={order.status} />
            </div>
            <div className="text-sm text-gray-600">
              {order.items.length} items · ${order.total.toFixed(2)}
            </div>
          </Link>
        ))}
      </div>
    </div>
  );
}

function StatusBadge({ status }: { status: string }) {
  const colors: Record<string, string> = {
    draft: 'bg-gray-200',
    confirmed: 'bg-blue-200',
    paid: 'bg-green-200',
    shipped: 'bg-purple-200',
    delivered: 'bg-green-400',
    cancelled: 'bg-red-200'
  };

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

// src/components/OrderTimeline.tsx
import { useOrderEvents } from '../hooks/useOrders';

export function OrderTimeline({ orderId }: { orderId: string }) {
  const { data: events, isLoading } = useOrderEvents(orderId);

  if (isLoading) return <div>Cargando timeline...</div>;

  return (
    <div className="space-y-4">
      <h3 className="text-lg font-semibold">Historial de Eventos</h3>
      <div className="relative border-l-2 border-gray-200 pl-4">
        {events?.map((event, index) => (
          <div key={event.eventId} className="mb-4 relative">
            <div className="absolute -left-6 w-3 h-3 bg-blue-500 rounded-full" />
            <div className="text-sm text-gray-500">
              {new Date(event.occurredAt).toLocaleString()}
            </div>
            <div className="font-medium">{formatEventType(event.eventType)}</div>
            <EventDetails event={event} />
          </div>
        ))}
      </div>
    </div>
  );
}

function formatEventType(type: string): string {
  return type.replace(/([A-Z])/g, ' $1').trim();
}

function EventDetails({ event }: { event: OrderEvent }) {
  switch (event.eventType) {
    case 'OrderCreated':
      return <div className="text-sm">Pedido creado con {event.data.items?.length} items</div>;
    case 'OrderConfirmed':
      return <div className="text-sm">Pedido confirmado</div>;
    case 'PaymentReceived':
      return <div className="text-sm">Pago de ${event.data.amount} recibido</div>;
    case 'OrderShipped':
      return <div className="text-sm">Enviado - Tracking: {event.data.trackingNumber}</div>;
    default:
      return null;
  }
}

Formulario de Creación

// src/components/CreateOrderForm.tsx
import { useState } from 'react';
import { useCreateOrder } from '../hooks/useOrderCommands';

interface FormState {
  customerId: string;
  items: Array<{ productId: string; quantity: number }>;
  street: string;
  city: string;
  state: string;
  zipCode: string;
}

export function CreateOrderForm({ onSuccess }: { onSuccess: () => void }) {
  const createOrder = useCreateOrder();
  const [form, setForm] = useState<FormState>({
    customerId: '',
    items: [{ productId: '', quantity: 1 }],
    street: '',
    city: '',
    state: '',
    zipCode: ''
  });

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();

    await createOrder.mutateAsync({
      customerId: form.customerId,
      items: form.items,
      shippingAddress: {
        street: form.street,
        city: form.city,
        state: form.state,
        zipCode: form.zipCode,
        country: 'US'
      }
    });

    onSuccess();
  };

  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      <div>
        <label className="block text-sm font-medium">Customer ID</label>
        <input
          type="text"
          value={form.customerId}
          onChange={e => setForm({ ...form, customerId: e.target.value })}
          className="mt-1 block w-full border rounded px-3 py-2"
          required
        />
      </div>

      <div>
        <label className="block text-sm font-medium">Dirección</label>
        <input
          type="text"
          placeholder="Calle"
          value={form.street}
          onChange={e => setForm({ ...form, street: e.target.value })}
          className="mt-1 block w-full border rounded px-3 py-2"
          required
        />
        <div className="grid grid-cols-3 gap-2 mt-2">
          <input
            type="text"
            placeholder="Ciudad"
            value={form.city}
            onChange={e => setForm({ ...form, city: e.target.value })}
            className="border rounded px-3 py-2"
            required
          />
          <input
            type="text"
            placeholder="Estado"
            value={form.state}
            onChange={e => setForm({ ...form, state: e.target.value })}
            className="border rounded px-3 py-2"
            required
          />
          <input
            type="text"
            placeholder="ZIP"
            value={form.zipCode}
            onChange={e => setForm({ ...form, zipCode: e.target.value })}
            className="border rounded px-3 py-2"
            required
          />
        </div>
      </div>

      <button
        type="submit"
        disabled={createOrder.isPending}
        className="w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700 disabled:opacity-50"
      >
        {createOrder.isPending ? 'Creando...' : 'Crear Pedido'}
      </button>
    </form>
  );
}

App Principal

// src/App.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { OrderList } from './components/OrderList';
import { OrderDetail } from './components/OrderDetail';

const queryClient = new QueryClient();

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <BrowserRouter>
        <div className="container mx-auto px-4 py-8">
          <h1 className="text-3xl font-bold mb-8">OrderFlow</h1>
          <Routes>
            <Route path="/" element={<OrderList />} />
            <Route path="/orders/:id" element={<OrderDetail />} />
          </Routes>
        </div>
      </BrowserRouter>
    </QueryClientProvider>
  );
}

Resumen

Glosario

Read Model (Modelo de Lectura)

Definición: Vista de datos optimizada para consultas específicas, generada por proyecciones que procesan eventos.

Por qué es importante: El frontend no consulta eventos directamente; consume read models diseñados para sus necesidades de visualización.

Ejemplo práctico: /orders devuelve {id, status, total, itemCount} optimizado para listar, no los 50 eventos de cada orden.


Comando (Command)

Definición: Objeto que expresa la intención de modificar el sistema. El backend decide si es válido.

Por qué es importante: Separa la intención del usuario del resultado. El comando puede rechazarse si viola reglas de negocio.

Ejemplo práctico: {type: 'ConfirmOrder', orderId: '123'} es un comando; el backend verifica si la orden está en estado válido para confirmar.


TanStack Query

Definición: Librería para manejo de estado del servidor en React que automatiza fetching, caching, sincronización y actualizaciones.

Por qué es importante: Elimina boilerplate de manejo de estado async y proporciona UX profesional (loading, error, refetch) out-of-the-box.

Ejemplo práctico: useQuery({queryKey: ['orders']}) cachea la lista de órdenes; al volver a la página, muestra cache mientras refresca en background.


Query Key

Definición: Array que identifica únicamente una query en el cache de TanStack Query.

Por qué es importante: Permite invalidar selectivamente partes del cache cuando datos relacionados cambian.

Ejemplo práctico: ['orders', '123'] identifica la orden 123; invalidateQueries({queryKey: ['orders', '123']}) refresca solo esa orden.


Mutation

Definición: En TanStack Query, operación que modifica datos del servidor (POST, PUT, DELETE).

Por qué es importante: Proporciona callbacks para manejar éxito, error, y actualización optimista de forma estructurada.

Ejemplo práctico: useMutation({mutationFn: confirmOrder, onSuccess: () => invalidateQueries('orders')}) confirma orden y refresca lista.


Zod Schema

Definición: Definición de esquema que permite validar datos en runtime y generar tipos TypeScript automáticamente.

Por qué es importante: Detecta desincronizaciones entre backend y frontend en desarrollo, antes de que causen errores en producción.

Ejemplo práctico: Si el backend agrega un campo nuevo, z.parse() falla con error descriptivo indicando qué campo falta o tiene tipo incorrecto.


Event Timeline

Definición: Visualización cronológica de eventos que afectaron a una entidad, mostrando qué pasó y cuándo.

Por qué es importante: Aprovecha la naturaleza del Event Sourcing para dar transparencia total al usuario sobre la historia de sus datos.

Ejemplo práctico: Timeline de orden muestra: Creada (10:00) -> Confirmada (10:05) -> Pago recibido (10:30) -> Enviada (14:00).


CQRS en Frontend

Definición: Separación de operaciones de lectura (queries a read models) y escritura (comandos al backend).

Por qué es importante: Refleja la arquitectura del backend y permite optimizar cada flujo independientemente.

Ejemplo práctico: Hooks separados useOrders() (query) y useConfirmOrder() (mutation) con diferentes configuraciones de cache.


← Capítulo 18: Event Upcasting | Capítulo 20: Optimistic UI →