Trading dashboards break in ways ordinary web apps do not. Data arrives continuously rather than on request, dozens of widgets depend on overlapping slices of the same state, and a frame dropped during a volatile minute is a real cost for the person watching the screen. This article walks through the architecture we used for TradeBot, a dashboard for operators running automated strategies across several exchanges, and the decisions that kept it responsive.

Separate the three kinds of state

The first mistake teams make is putting everything in one store. A trading UI has three kinds of state with different lifecycles:

  1. Server state — things that live in a database and change on request: account settings, strategy definitions, historical fills, saved layouts. It is fetched, cached and invalidated.
  2. Streaming state — things that arrive continuously over a socket: order books, tickers, positions, open orders, PnL. It is pushed, normalised and merged.
  3. UI state — things that only matter in the browser: selected market, active tab, open modals, column widths.

We use TanStack Query for server state, a Zustand store for streaming and UI state, and we never let one leak into the other. Server state gets request deduplication, background refetching and cache invalidation for free. Streaming state gets a flat, normalised shape and selective subscriptions. UI state stays local and cheap.

The payoff is that each kind of state can be optimised independently. Query cache settings have nothing to do with socket throughput; socket throughput has nothing to do with how often a modal re-renders.

Normalise the stream before it touches React

Exchange feeds are messy. One venue sends full order book snapshots every second; another sends deltas with sequence numbers; a third sends positions in a different schema than its fills. If this diversity reaches your components, every widget becomes a translation layer.

Our rule: a single stream layer, outside React, owns the socket connections and converts every message into one of a small set of internal events — book.snapshot, book.delta, ticker, position, order, fill. The store applies those events to a normalised structure keyed by venue and market:

type Store = {
  books: Record<MarketId, OrderBook>;
  tickers: Record<MarketId, Ticker>;
  positions: Record<PositionId, Position>;
  orders: Record<OrderId, Order>;
  connection: Record<Venue, "live" | "reconnecting" | "stale">;
};

Components never see venue-specific payloads. They subscribe to books[marketId] or positions and nothing else. When a new venue is added, the stream layer gains an adapter and nothing in the component tree changes.

Render only what changed

React can comfortably handle thousands of updates per second if each update re-renders a small subtree. It cannot if each update re-renders the dashboard. Three techniques do most of the work:

Selective subscriptions. Each widget subscribes to the narrowest slice it needs with a selector, and the store only notifies subscribers whose slice changed by reference. An order book widget for BTC/USDT does not re-render when ETH/USDT ticks.

Batching and throttling at the boundary. Order book deltas can arrive faster than a monitor refreshes. The stream layer batches deltas and flushes them to the store at most once per animation frame. Nothing visible is lost — the book is correct after each flush — and React work drops by an order of magnitude during bursts.

Stable identities for rows. Tables of orders and fills use stable keys and memoised row components, so a new fill appends one row instead of reconciling the entire list. Virtualised lists keep long histories cheap.

Charts follow the same principle. We feed the charting library incremental updates (update(lastBar)) rather than replacing the series, and we keep chart state outside React entirely, since the library manages its own canvas.

Design for disconnects

Sockets drop. Venues restart. Laptops sleep. A dashboard that silently shows yesterday's position is worse than one that shows nothing. We treat connection state as first-class data:

  • Each venue has a status — live, reconnecting, stale — derived from heartbeats and last-message timestamps, and every widget that depends on that venue renders an unambiguous indicator when it is not live.
  • Reconnects use exponential backoff with jitter and resubscribe automatically. After reconnecting, the stream layer requests a fresh snapshot for each subscribed book and discards deltas with sequence numbers older than the snapshot.
  • Server-state queries that affect decisions (open orders, positions) are refetched after a reconnect, because the socket may have missed events while down.

This is unglamorous work and it is the difference between a demo and a tool people trust with money.

Operator controls need friction

TradeBot is not a passive display. Operators pause and resume strategies, adjust parameters and place manual orders. Every one of those actions can lose money if it is a misclick, so the controls are deliberately slower than the data:

  • Destructive or money-moving actions require a confirmation step that restates what will happen ("Cancel 12 open orders on Binance BTC/USDT?").
  • Parameter changes are staged and applied explicitly, not on every keystroke.
  • Every action is written to an action history with who, what, when and the resulting server response, visible in the dashboard itself.

We also make the optimistic-update decision per action. Cancelling an order updates the UI immediately and reconciles with the server; placing an order waits for acknowledgement, because an order that appears placed and is not is a worse failure than a short delay.

Layout and personalisation

Operators watch different things. We shipped configurable layouts — a grid of widgets per user with sizes and positions — and per-user watchlists, persisted as server state. Layouts are JSON documents validated with a schema so that an old layout survives a new version of the dashboard. The default layout is the one we expected most people to need; in practice almost everyone changed it within a day, which is the point.

What we would do again, and what we would change

Again: the three-state split, the normalised stream layer and per-venue connection status. They are the foundation that let us add venues and widgets without touching existing code.

Change: we initially allowed widgets to subscribe to whole books rather than top-N levels. For a 200-level book this was wasted work; most widgets only render 20 levels. Expose depth-limited selectors from the start.

Also change: we under-invested in a replay tool early. Being able to record a stream session and replay it against the UI makes performance work and bug reproduction dramatically easier. Build it in the first month.

Summary

A trading dashboard is a streaming system with a React front end attached, not a React app that happens to use WebSockets. Keep streaming state out of components, normalise aggressively, render selectively, treat connection status as data and add friction to actions that move money. Do that and the framework choice matters far less than people assume.