Interfaz de usuario para operaciones financieras adaptativa con SSE, virtualización y un presupuesto para 20 mercados
Coordine las filas virtualizadas de React, un planificador central de suscripciones SSE y un almacén externo de cotizaciones para que los tickets abiertos mantengan precios actualizados sin exceder los límites de conexión.
Un registro de operaciones puede listar miles de instrumentos mientras solo unas pocas filas se muestran en la pantalla. Las cotizaciones fluyen continuamente, el usuario hace scroll y una orden abierta aún puede necesitar precios en tiempo real incluso después de que su mercado salga del área visible.
Esos problemas tienen criterios diferentes:
Scrolling changes visibility.
Subscription policy changes network demand.
SSE changes quote data.
React changes what is displayed.
Asigne un único responsable a cada problema. Los fragmentos siguientes ilustran esa división; se trata de partes arquitectónicas, no de un cliente de operaciones completo.
Defina las reglas antes de elegir la implementación
Supongamos que el backend permite como máximo 20 IDs de mercado por conexión SSE. La política entonces sería:
- Un
EventSourceactivo por pestaña del navegador - Nunca más de 20 IDs de mercado distintos en esa conexión
- Una orden abierta tiene prioridad sobre las filas normales de la lista
- La demanda puede cambiar sin tener que eliminar el registro del consumidor
La arquitectura
Virtualized list ─── visible and nearby market IDs ──┐
├─► Subscription manager
Open order ticket ─── selected market ID ───────────┘ │
▼
One SSE connection
│
Validated messages
▼
Quote store
/ \
▼ ▼
React snapshots Order validation
Los componentes React declaran las solicitudes y leen los snapshots. Ellos no abren ni gestionan sockets.
1. Almacenar las cotizaciones actuales de forma independiente de React
Las cotizaciones validadas tienen el siguiente formato:
type Quote = Readonly<{
marketId: string;
bid: string; // Decimal strings preserve wire precision.
ask: string;
version: number;
staleAt: number; // Server expiry timestamp in milliseconds.
tradable: boolean;
}>;
Los snapshots exponen tanto el estado de disponibilidad como el contenido:
type QuoteSnapshot =
| { state: "waiting"; quote: null }
| { state: "fresh" | "stale"; quote: Quote };
La superficie del almacén se mantiene reducida:
interface QuoteStore {
receive(quote: Quote): void;
// Immediate state, used by commands.
readLatest(marketId: string): QuoteSnapshot; // Cached visual snapshots, used by React.
select(marketId: string): {
subscribe(notify: () => void): () => void;
getSnapshot(): QuoteSnapshot;
}; invalidate(marketIds: readonly string[]): void;
retain(marketIds: readonly string[]): void;
}
La lógica de recepción compara las versiones antes de aceptar una actualización:
function receiveQuote(next: Quote) {
const previous = latestQuotes.get(next.marketId);
// This example assumes strictly increasing quote versions.
if (previous && next.version <= previous.version) return; latestQuotes.set(next.marketId, next); // Independent of whether another price message arrives.
scheduleQuoteExpiry(next.marketId, next.staleAt); // Ordinary prices can wait for the next visual publication.
scheduleVisualPublication();
}
Los temporizadores de vencimiento, la invalidación y la distribución a los suscriptores se gestionan directamente en la tienda. El historial no es ilimitado: solo se mantiene la última cotización por mercado:
Market A, version 10
↓ replaced
Market A, version 11
↓ replaced
Market A, version 12
Si el servidor reutiliza espacios de versión entre diferentes tipos de marcos, el reductor debe fusionarlos según dicho contrato.
2. Asignar las 20 suscripciones de forma centralizada
Los consumidores anuncian su demanda con un nivel de prioridad:
type Demand = {
marketIds: readonly string[];
priority: number;
};
const PRIORITY = {
orderTicket: 0,
visible: 1,
nearby: 2,
} as const;const MAX_MARKETS = 20;
Un planificador elimina las duplicadas y mantiene la prioridad más alta por mercado:
function planSubscriptions(
demands: readonly Demand[],
activeIds: readonly string[],
): string[] {
const priorities = new Map<string, number>();
for (const demand of demands) {
for (const marketId of demand.marketIds) {
priorities.set(
marketId,
Math.min(
priorities.get(marketId) ?? Infinity,
demand.priority,
),
);
}
} if (priorities.size === 0) return []; const active = new Set(activeIds); const requested = [...priorities.keys()].sort(
(left, right) =>
priorities.get(left)! - priorities.get(right)! ||
Number(active.has(right)) - Number(active.has(left)),
); return [...new Set([...requested, ...activeIds])]
.slice(0, MAX_MARKETS)
.sort();
}
Cuando la demanda supera las veinte mercados, las filas excedentes permanecen visiblemente en espera. La interfaz de usuario no debe dar la impresión de que cuentan con cobertura en tiempo real.
3. Actualizar la demanda sin reiniciar un efecto de React
Cada consumidor cuenta con un propietario estable:
function useMarketDemand(
marketIds: readonly string[],
priority: number,
) {
const [owner] = useState(() =>
subscriptionManager.createOwner(),
);
const key = JSON.stringify([...new Set(marketIds)]);
const stableIds = useMemo<string[]>(
() => JSON.parse(key),
[key],
); useEffect(() => {
owner.update({
marketIds: stableIds,
priority,
});
}, [owner, stableIds, priority]); useEffect(() => {
return () => owner.dispose();
}, [owner]);
}
Cuando cambia el ID, ese propietario se actualiza en su lugar; al desmontar la componente, se elimina.
El rápido cambio en la demanda se procesa en lotes dentro de un período de programación fijo:
let reconciliationTimer:
ReturnType<typeof setTimeout> | undefined;
function scheduleReconciliation() {
if (reconciliationTimer !== undefined) return; reconciliationTimer = setTimeout(() => {
reconciliationTimer = undefined;
reconcileSubscriptions();
}, 150);
}
Dado que el período es fijo y no se trata de un mecanismo desactivador dinámico, el desplazamiento continuo no puede posponer la reconciliación indefinidamente. La conexión inicial y el cierre final aún pueden realizarse de inmediato.
4. Reemplazar la conexión SSE de forma segura
Nuevos parámetros de consulta implican un nuevo EventSource. Cierre la transmisión anterior antes de abrir la siguiente:
let source: EventSource | undefined;
let generation = 0;
let clearWatchdog: (() => void) | undefined;
function replaceConnection(marketIds: string[]) {
const currentGeneration = ++generation; clearWatchdog?.();
source?.close();
source = undefined; quoteStore.retain(marketIds);
quoteStore.invalidate(marketIds); if (marketIds.length === 0 || !navigator.onLine) return; const query = new URLSearchParams(); for (const marketId of marketIds) {
query.append("marketId", marketId);
} const nextSource = new EventSource(
`/api/v1/stream?${query.toString()}`,
); source = nextSource;
const permittedIds = new Set(marketIds); const watchdog = createSilenceWatchdog(() => {
if (currentGeneration !== generation) return; nextSource.close();
reconcileSubscriptions({ force: true });
}); clearWatchdog = watchdog.stop;
watchdog.reset(); nextSource.addEventListener("quote", event => {
if (currentGeneration !== generation) return; // Parses JSON and validates it against the feed contract.
const quote = parseQuoteMessage(event); if (!quote || !permittedIds.has(quote.marketId)) {
quoteStore.invalidate(marketIds);
return;
} watchdog.reset();
quoteStore.receive(quote);
}); nextSource.addEventListener("heartbeat", () => {
if (currentGeneration === generation) {
watchdog.reset();
}
}); nextSource.onerror = () => {
if (currentGeneration !== generation) return; quoteStore.invalidate(marketIds); if (nextSource.readyState === EventSource.CLOSED) {
watchdog.stop();
reportConnectionError();
} // Recoverable failures are retried by native EventSource.
};
}
Los auxiliares para el monitoreo, el análisis y el estado de la conexión permanecen en módulos separados. Un contador de generaciones descarta los eventos posteriores provenientes de sockets obsoletos. El EventSource nativo se reconecta ante errores recuperables; llamar a .close() finaliza esa instancia. Consulte la guía de MDN sobre el uso de eventos enviados por servidor para conocer el comportamiento del navegador.
Los listeners en modo online/offline y relacionados con el ciclo de vida de la página se encuentran al nivel del gestor: al pasar a modo offline, las cotizaciones se invalidan y el socket se cierra; la recuperación vuelve a conectarse desde la solicitud actual. Una nueva conexión espera nuevas cotizaciones antes de permitir el envío.
5. Virtualizar la lista e indicar su área de visualización
La virtualización limita el DOM montado; el planificador de suscripciones limita por separado los mercados transmitidos en streaming. Con TanStack Virtual, las filas visibles pueden solicitar una prioridad mayor que sus vecinas en el área de escaneo adicional:
function MarketList({
markets,
onOpenTicket,
}: {
markets: Array<{ marketId: string; name: string }>;
onOpenTicket(marketId: string, side: "BUY" | "SELL"): void;
}) {
const [viewport, setViewport] =
useState<HTMLDivElement | null>(null);
const virtualizer = useVirtualizer({
count: markets.length,
getScrollElement: () => viewport,
getItemKey: index => markets[index]!.marketId,
estimateSize: () => 72,
overscan: 2,
}); const rows = virtualizer.getVirtualItems();
const range = virtualizer.range; const visibleIds = range
? markets
.slice(range.startIndex, range.endIndex + 1)
.map(market => market.marketId)
: []; const nearbyIds = rows.map(
row => markets[row.index]!.marketId,
); useMarketDemand(visibleIds, PRIORITY.visible);
useMarketDemand(nearbyIds, PRIORITY.nearby); return (
<div
ref={setViewport}
role="region"
aria-label="Markets"
tabIndex={0}
style={{ height: 560, overflow: "auto" }}
>
<div
style={{
height: virtualizer.getTotalSize(),
position: "relative",
}}
>
{rows.map(row => (
<div
key={row.key}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: 72,
transform: `translateY(${row.start}px)`,
}}
>
<MarketRow
market={markets[row.index]!}
onOpenTicket={onOpenTicket}
/>
</div>
))}
</div>
</div>
);
}
Estos ejemplos suponen una altura fija de las filas; las alturas variables requieren mediciones. El virtualizador monta las filas; la política de suscripción sigue siendo propiedad de la aplicación (ver los documentos de React de TanStack Virtual). El enfoque del teclado también debe mantener disponible una fila enfocada cuando esta sale de la ventana de renderizado normal.
6. Renderizar cada mercado a través de su propio snapshot
function useQuote(marketId: string) {
const selection = useMemo(
() => quoteStore.select(marketId),
[marketId],
);
return useSyncExternalStore(
selection.subscribe,
selection.getSnapshot,
);
}
Mientras no cambie algo significativo en ese mercado, select sigue devolviendo la misma referencia de captura instantánea: exactamente lo que React espera de un almacén externo utilizado con useSyncExternalStore.
const MarketRow = memo(function MarketRow({
market,
onOpenTicket,
}: {
market: { marketId: string; name: string };
onOpenTicket(marketId: string, side: "BUY" | "SELL"): void;
}) {
const snapshot = useQuote(market.marketId);
const quote = snapshot.quote;
const available =
snapshot.state === "fresh" && quote?.tradable; return (
<article>
<strong>{market.name}</strong> <button
disabled={!available}
onClick={() => onOpenTicket(market.marketId, "SELL")}
>
Sell · {quote?.bid ?? "—"}
</button> <button
disabled={!available}
onClick={() => onOpenTicket(market.marketId, "BUY")}
>
Buy · {quote?.ask ?? "—"}
</button> {snapshot.state !== "fresh" && (
<span>Prices updating</span>
)}
</article>
);
});
Un cambio en la cotización del Mercado A solo notifica al lector de ese mercado; no reemplaza todo el array de mercados del listado principal. Los botones abren un ticket; no realizan pedidos de forma silenciosa.
El ticket declara su propia demanda:
function OrderTicket({ marketId }: { marketId: string }) {
useMarketDemand([marketId], PRIORITY.orderTicket);
const snapshot = useQuote(marketId); // Render quantity, side, current quote, review and submit controls.
// ...
}
Desplazarse por la cuadrícula no cancela la solicitud del ticket. Eliminar el ticket solo quita a ese propietario; una fila visible o una entrada de escaneo adicional cercana podría seguir necesitando el mismo instrumento.
Revisa nuevamente la cotización en tiempo real justo antes de comprar o vender
Lo que se pintó en la última fila puede hacer que la tienda autorizada tarde un momento. Por lo tanto, la ruta de envío se lee nuevamente:
async function submitOrder(intent: {
clientOrderId: string; // Stable for retries of this exact intent.
marketId: string;
side: "BUY" | "SELL";
quantity: string;
reviewedVersion: number;
}) {
const snapshot = quoteStore.readLatest(intent.marketId);
const quote = snapshot.quote;
if (
!navigator.onLine ||
snapshot.state !== "fresh" ||
!quote ||
!quote.tradable ||
Date.now() >= quote.staleAt
) {
throw new Error("Wait for a fresh, tradable quote.");
} if (quote.version !== intent.reviewedVersion) {
throw new Error("The quote changed. Review it again.");
} const limitPrice =
intent.side === "BUY" ? quote.ask : quote.bid; const response = await fetch("/api/v1/orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...intent,
type: "LIMIT",
limitPrice,
}),
}); return parseOrderResponse(response);
}
Las respuestas se analizan para determinar si son aceptadas, rechazadas o ambiguas. Al intentarlo de nuevo, se reutiliza el identificador original del pedido del cliente. La autorización, la verificación de cotizaciones, la aceptación y los llenados son decisiones que se toman en el servidor. Los precios límite solo limitan qué ejecuciones están permitidas; nunca garantizan un llenado.
Las dos decisiones de temporización
| Decisión | Propósito |
|---|---|
| Período de suscripción de ~150 ms | Unir los cambios rápidos en la vista antes de reemplazar la conexión |
| Intervalo de publicación visual de ~100 ms | Limitar la frecuencia habitual de actualización de precios |
Los mensajes entrantes siguen actualizando inmediatamente la tienda correspondiente. La disponibilidad cambia sin experimentar la habitual demora visual. El intervalo de 150 ms no está diseñado para ralentizar el desplazamiento, los clics ni los precios que ya llegan a la conexión activa.
Memoria y limpieza
Un caché de catálogos (por ejemplo, React Query) necesita sus propias reglas de retención. Una lista virtualizada puede mostrar diez filas mientras sigue almacenando en caché miles de registros cargados. Mantenga las tareas separadas: la virtualización se encarga de la interfaz gráfica mostrada, el planificador gestiona la capacidad de suscripción, la tienda controla el estado de las cotizaciones, y React dibuja los instantáneos relevantes.
Lecturas relacionadas
- Construir un panel de control al estilo Sonara con Clerk orgs y paso a PDT — Armar un panel después del inicio de sesión: barra lateral que reconoce la organización, fondo de ondas en forma de lienzo, estimación en tiempo real de costos mediante PDT y comandos de ejemplo con un solo clic.
- Flujos de trabajo para profesores de instrumentos con autorecogida de PostHog y eventos personalizados — Integrar la autorecogida de PostHog, eventos de resultados, reproducción de sesiones y líneas de tiempo individuales para que los productos educativos asistidos por IA midan las tareas completadas, no solo los clics.