Capítulo 19: Frontend React con Event Sourcing
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:
- Leer proyecciones (Read Models): Datos optimizados para visualización, actualizados por proyecciones del backend
- 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:
- queryKey: Identificador único para cachear resultados
- staleTime: Tiempo antes de considerar datos “viejos”
- invalidateQueries: Marca datos como obsoletos para refetch
// 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:
- Llamar al hook para obtener datos/mutaciones
- Manejar estados de carga y error
- 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
- El frontend consume read models optimizados
- Los comandos se envían al backend para modificar estado
- TanStack Query maneja cache e invalidación
- El timeline muestra el historial de eventos
- La separación queries/commands refleja CQRS
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 →