← Back to blog

Connection Status, Loading, and Empty States in Trading UIs

What to show when the data is not there yet - skeletons, stale-data indicators, reconnection banners, and honest empty states for trading panels.

Oliver Benns
Oliver Benns

Software engineer · Creator of Hedge UI


Most trading UI bugs are not arithmetic. They are presence bugs: the layout assumes a number is there, the number is not there yet, and the panel either explodes or - worse - shows something that looks plausible but is wrong. A price that rendered three seconds ago and has not updated since looks exactly like a live price. A balances table that is empty because the request failed looks exactly like a balances table that is empty because you genuinely hold nothing.

In a content site you can paper over this with a spinner and move on. In a trading interface the gap between "loading", "loaded", "empty", and "stale" is the difference between a user placing a sensible order and a user placing an order against a frozen book. This post walks through how to model those states explicitly, what to render for each, and where the Hedge UI demo gets it right and where it currently does not.

The four states every panel has

Every data-bound panel is always in exactly one of four states. The mistake is treating the world as binary (loading or loaded) and letting the other two leak through as a misleading "loaded".

StateWhat it meansWhat a trading panel must show
LoadingNo data has arrived yet (first connect, first fetch)A skeleton shaped like the panel, never a frozen "0.00"
LoadedFresh data is flowingThe data, plus a quiet liveness signal
EmptyRequest succeeded, the result set is genuinely emptyHonest copy with a hint, not a blank box
Error / staleConnection dropped, request failed, or the socket went silentA visible, non-dismissable warning that the numbers cannot be trusted

The first two are forgiving. Show the wrong thing for half a second during loading and nobody is harmed. The last two are where money is lost. An empty panel that should have shown an error reads as "you have no open orders" when in fact the request failed and you have plenty. A stale price that still renders crisply invites a market order at a number that no longer exists. A good chunk of the things trading UI projects get wrong come down to collapsing these four conditions into two.

A useful discipline is to make the state an explicit value rather than something you infer from data == null at three different call sites:

type PanelStatus = "loading" | "ready" | "empty" | "stale" | "error"; type PanelState<T> = | { status: "loading" } | { status: "ready"; data: T; updatedAt: number } | { status: "empty" } | { status: "stale"; data: T; updatedAt: number } | { status: "error"; error: Error };

Once the status is a discriminated union, the render path is a switch and the "show stale data as if live" bug becomes hard to write by accident.

Loading: skeletons over spinners

A centered spinner tells the user that something is happening. A skeleton tells them what is about to appear, and it does so without a layout shift when the real content lands. For panels with a known shape - an order book with fixed-height rows, a balances table, a price header - the skeleton should mirror that shape: the same number of rows, the same column widths, a muted animate-pulse block where each value will be.

const OrderBookSkeleton = ({ rows }: { rows: number }) => ( <div className="flex flex-col" aria-hidden> {Array.from({ length: rows }).map((_, i) => ( <div key={i} className="grid grid-cols-3 gap-2 px-2 py-1"> <div className="h-3 animate-pulse rounded bg-muted" /> <div className="h-3 animate-pulse rounded bg-muted justify-self-end w-3/4" /> <div className="h-3 animate-pulse rounded bg-muted justify-self-end w-2/3" /> </div> ))} </div> );

Here is the honest part. The demo does not do this yet. The order book, the price chart, and every table built on DataTable render a spinning Loader2Icon from lucide-react while loading:

// src/features/order-book/order-book.tsx {isLoading ? ( <div className="flex justify-center items-center h-24"> <Spinner /> </div> ) : ( <> <OrderBookSide levels={asks} side="ask" /* ... */ /> <OrderBookSide levels={bids} side="bid" /* ... */ /> </> )}

Spinner is two lines: <Loader2Icon className="animate-spin" />. It is honest about "nothing here yet" and it is cheap, which is why it shipped. The trade-off is that the panel collapses to a 24px-high spinner box and then jumps to its full height when data arrives. The order book already knows its target shape - it derives the number of visible levels from the container height via a ResizeObserver, dividing the available pixels by 24px rows - so a skeleton with that exact row count is the natural upgrade. That is the kind of refinement a production build layers on top of the starter. More on how those rows get sized in building a real-time order book in React.

First connect versus reconnect

"Loading" is not one event. The first connect (nothing on screen, user is waiting) and a reconnect (data was on screen, the socket dropped, you are re-establishing it) deserve different treatment. On first connect a skeleton is right. On reconnect, blowing the existing data away and dropping back to a skeleton is the wrong move - the last book is more useful than a blank one, as long as you mark it stale.

The demo's stream layer is built on react-use-websocket 4.13.0, which exposes a readyState you can map straight to UI intent:

import { ReadyState } from "react-use-websocket"; const connectionLabel: Record<ReadyState, string> = { [ReadyState.UNINSTANTIATED]: "Idle", [ReadyState.CONNECTING]: "Connecting", [ReadyState.OPEN]: "Live", [ReadyState.CLOSING]: "Closing", [ReadyState.CLOSED]: "Reconnecting", // the lib retries, so CLOSED reads as reconnecting };

The distinction between a cold start and a recovery, plus how the single multiplexed socket diffs subscriptions instead of tearing the connection down, is the whole subject of websocket state management for crypto trading. The UI rule that falls out of it: keep the last good data visible during a reconnect, dim it, and show a banner. Do not flash a skeleton at someone who already had a working screen.

Stale data: when the socket goes quiet

The most dangerous state has no error. The socket is open, readyState is OPEN, but messages have stopped arriving - a wedged proxy, a backgrounded tab, an exchange-side hiccup. Nothing throws. The last price just sits there looking authoritative.

The defence is a freshness clock. Record updatedAt on every message and compare it to now. Past a threshold, switch the panel to a stale presentation: dim the values, show a relative timestamp, and stop pretending the number is live.

const STALE_AFTER_MS = 5000; const useFreshness = (updatedAt: number | undefined) => { const [now, setNow] = useState(() => Date.now()); useEffect(() => { const id = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(id); }, []); if (updatedAt === undefined) { return { isStale: true, age: Infinity }; } const age = now - updatedAt; return { isStale: age > STALE_AFTER_MS, age }; };
<div className={cn("tabular-nums", isStale && "opacity-40")}> {formatCurrency(price, quote.precision)} {isStale && ( <span className="ml-2 text-xs text-muted-foreground"> last updated {Math.round(age / 1000)}s ago </span> )} </div>

There is a second, sneakier kind of staleness: data that is fresh but belongs to the wrong market. When a user switches from BTC to ETH, the BTC socket message that was already in flight can land a frame after the switch and briefly paint BTC numbers under the ETH header. The demo guards against this with useLastValidMessage, which drops any message whose stream is no longer in the active subscription set:

// src/binance/binance-stream.ts export const useLastValidMessage = <T>( lastJsonMessage: unknown | null, streamNames: string[], ) => { const lastValidMessageRef = useRef<Message<T>>(undefined); const lastValidMessage = useMemo(() => { if (!lastJsonMessage) { return lastValidMessageRef.current; } if (typeof lastJsonMessage === "object" && "stream" in lastJsonMessage) { const message = lastJsonMessage as Message<T>; // If we have changed products, the last message might be stale from the old product. const isCurrentProduct = streamNames.includes(message.stream); if (!isCurrentProduct) { return; // clear, rather than render the previous market's data } return message; } return lastValidMessageRef.current; }, [lastJsonMessage, streamNames]); return lastValidMessage; };

Returning undefined for a non-matching stream is deliberate - it clears the panel for one tick rather than showing the prior product's book. A sibling hook, useStreamCache, keeps the latest message per stream and prunes cache entries for streams that have dropped out of the subscription list. If you batch or throttle incoming ticks - the open-orders panel runs its ticker streams through a 1000ms throttle - make sure the freshness clock reads the arrival time of the raw message, not the time your throttle chose to flush it, or you will report data as older than it is.

Empty states: honest and actionable

An empty panel should answer two questions: is this empty because there is nothing, or because something broke, and what should I do about it. "No results." answers neither.

That string is exactly what the shared DataTable renders today:

// src/components/data-table.tsx ) : ( <TableRow className="transition-none text-xs"> <TableCell colSpan={columns.length} className="h-24 text-center"> No results. </TableCell> </TableRow> )}

It works because the demo rarely reaches it. The open-orders panel generates 100 synthetic rows at module load, so in practice the table is either loading or full. The empty branch is real code that almost never runs, which is precisely the kind of path that ships broken because nobody sees it. A real account's open-orders and positions panels will be empty regularly, and the copy needs to earn its space:

PanelWeakHonest and actionable
Open ordersNo results.No open orders. Place an order from the form to see it here.
PositionsNo results.No open positions.
BalancesNo results.No balances yet. Deposit to get started.
Trade historyNo results.No trades in this period. Try a wider date range.
const EmptyState = ({ title, hint }: { title: string; hint?: string }) => ( <div className="flex h-full flex-col items-center justify-center gap-1 p-6 text-center"> <p className="text-sm text-foreground">{title}</p> {hint && <p className="text-xs text-muted-foreground">{hint}</p>} </div> );

One more subtlety from the demo worth flagging: the open-orders hook computes isLoading: rows.length === 0. Loading is inferred from an empty array. That is fine when the data set is always non-empty, but it conflates "still loading" with "genuinely empty". A panel that can legitimately be empty needs a real loading flag from the data source, separate from the row count, or the empty state can never render.

Error states and the connection banner

Errors come in two flavours and they want different surfaces. A transient event - an order rejected, a single failed fetch that will be retried - is a toast. An ongoing condition - the socket is down, the whole screen is stale - is a banner that stays until the condition clears. Toasts are the wrong tool for persistent problems because they disappear, and a disappearing "you are disconnected" message is worse than none.

A connection-status indicator belongs somewhere always-visible, typically the header. Drive it off the same readyState:

const ConnectionStatus = ({ state }: { state: ReadyState }) => { const ok = state === ReadyState.OPEN; return ( <div className="flex items-center gap-1.5 text-xs" role="status" aria-live="polite"> <span className={cn("size-2 rounded-full", ok ? "bg-emerald-500" : "bg-amber-500 animate-pulse")} /> {ok ? "Live" : "Reconnecting"} </div> ); };

The role="status" and aria-live="polite" matter: a colour-only dot is invisible to a screen reader and to anyone with red-green colour blindness, so the state has to be conveyed in text too, and announced rather than only painted.

Graceful failure is not only about sockets. When the region lookup in ClientLocationProvider fails, the demo does not throw or block - it catches the error, defaults the region to "EU", and carries on, choosing the correct Binance endpoint as a side effect. That pattern, picking a safe default instead of a hard error, is exactly the right instinct for geolocation and regional API routing. The same provider does gate its children behind isLoading, rendering null until the lookup resolves, which is a reasonable trade for a one-time startup check but would be the wrong move for a per-tick stream.

The demo's current gap

Being candid: the demo's notification story is one-directional. The order form fires toast.success(...) through sonner 2.0.7 on a valid submit, and that is the entire toast surface. There is no error toast for a rejected order, no connection-lost banner, and no stale-data indicator wired up to the streams.

// src/features/order-form/order-form-panel.tsx const msg = `Order submitted: ${side.toUpperCase()} ${fieldValues.amount} ` + `${product.base.code} @ ${fieldValues.limitPrice} ${product.quote.code}`; toast.success(msg);

A production build layers on the missing pieces:

  • toast.error(...) on order rejection, failed cancels, and fetch failures, with the reason.
  • A persistent connection banner driven by readyState, dismissable only when the socket reopens.
  • Per-panel stale badges using the freshness clock above.
  • A small notification centre so transient toasts are not the only record of what happened.

The full taxonomy of transient versus persistent messaging, and how to build the centre, is the subject of a notification system for trading alerts in React. The radix primitives the demo already ships - progress, scroll-area, tooltip - plus lucide-react icons are enough to build all of the above without new dependencies.

Per-panel resilience

The last principle ties the others together: a failure in one panel must not blank the screen. Hedge UI's layout is a grid of independent panels, each subscribing to its own streams. If the trades feed dies, the order book and the order form should keep working, and only the trades panel should show its error state.

That isolation does not happen for free. An unhandled render error in one panel will, by default, unmount the entire React tree. Wrapping each panel in its own error boundary contains the blast radius so a single bad message degrades one box instead of the whole terminal. The reasoning and the boundary component are in error boundaries and graceful degradation for trading UIs. Combined with explicit per-panel status, the result is a screen that loses a corner under failure rather than going dark.

A shipping checklist

Before a panel is done, walk it through all four states by hand:

  • Loading: skeleton matches the loaded shape, no layout shift when data lands, no frozen zeros.
  • First connect vs reconnect: cold start shows a skeleton; reconnect keeps dimmed last-good data plus a banner.
  • Stale: a freshness clock dims values and shows "last updated Ns ago" past the threshold, reading arrival time not flush time.
  • Product switch: in-flight messages from the previous market are dropped, not painted under the new header.
  • Empty: honest, specific copy with a hint, and a real loading flag so empty is distinguishable from loading.
  • Error: transient failures toast, ongoing failures get a persistent banner, and the connection status is always visible.
  • Accessibility: status conveyed in text, not colour alone, and announced with aria-live.
  • Isolation: each panel is wrapped so one dead stream degrades one box, not the whole screen.

Get those eight right and the interface stops lying. The numbers on screen are either trustworthy or visibly flagged as not, and a user can never mistake a frozen book for a live one. In a tool people trade real money through, that honesty is the feature.

Kickstart Your Trading Application

Hedge UI is a React starter kit with production-ready trading components, real-time data handling, and customisable layouts — so you can ship faster.

Get Hedge UI