In most web applications, an unhandled error in one component takes down the entire page. In a trading application, that is unacceptable. A crash in the chart tooltip should not prevent a trader from managing their open orders. A broken position panel should not make the order form inaccessible.
Graceful degradation is not about preventing errors. It is about containing them.
The cost of a full-page crash
When a React component throws during rendering, React unmounts the entire tree unless an error boundary catches it. In a trading context, this means:
- Open order forms lose their input state
- The trader cannot see their positions or P&L
- The WebSocket connection may be abandoned, missing fills
- The trader has to reload, re-authenticate, and reconstruct their workspace
A single uncaught TypeError in a chart overlay can cause all of this. The fix is straightforward but requires deliberate architecture.
Strategic error boundary placement
Error boundaries should wrap each independent panel or widget, not the entire application. The granularity depends on your layout, but a good starting point:
function TradingWorkspace() { return ( <WorkspaceLayout> <ErrorBoundary fallback={<PanelError name="Chart" />}> <ChartPanel /> </ErrorBoundary> <ErrorBoundary fallback={<PanelError name="Order Book" />}> <OrderBookPanel /> </ErrorBoundary> <ErrorBoundary fallback={<PanelError name="Orders" />}> <OrdersPanel /> </ErrorBoundary> <ErrorBoundary fallback={<PanelError name="Positions" />}> <PositionsPanel /> </ErrorBoundary> <ErrorBoundary fallback={<PanelError name="Order Form" />}> <OrderFormPanel /> </ErrorBoundary> </WorkspaceLayout> ); }
When the chart crashes, the trader still sees their orders, positions, and can submit new orders. The chart panel shows an error state with a retry button.
Building a useful error boundary
React's error boundary API is class-based. Wrap it in a component that provides useful recovery options:
interface ErrorBoundaryProps { fallback: React.ReactNode; onError?: (error: Error, errorInfo: React.ErrorInfo) => void; children: React.ReactNode; } interface ErrorBoundaryState { hasError: boolean; error: Error | null; } class ErrorBoundary extends React.Component< ErrorBoundaryProps, ErrorBoundaryState > { state: ErrorBoundaryState = { hasError: false, error: null }; static getDerivedStateFromError(error: Error) { return { hasError: true, error }; } componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { this.props.onError?.(error, errorInfo); } reset = () => { this.setState({ hasError: false, error: null }); }; render() { if (this.state.hasError) { return ( <ErrorBoundaryContext.Provider value={{ reset: this.reset, error: this.state.error }} > {this.props.fallback} </ErrorBoundaryContext.Provider> ); } return this.props.children; } }
The reset function is key. It allows the fallback UI to offer a "Try again" button that re-mounts the failed component.
Fallback UI design
A good fallback for a trading panel should:
- Identify the broken component - "Chart could not be loaded" is more helpful than a generic error
- Offer recovery - A "Retry" button that resets the error boundary
- Preserve layout - Take up the same space as the original panel to avoid layout shifts
- Not block interaction - The fallback should not be a modal or overlay that prevents access to other panels
function PanelError({ name }: { name: string }) { const { reset } = useErrorBoundary(); return ( <div className="flex h-full flex-col items-center justify-center gap-3 bg-muted/10 p-4"> <AlertCircle className="h-8 w-8 text-muted-foreground" /> <p className="text-sm text-muted-foreground">{name} failed to load</p> <Button variant="outline" size="sm" onClick={reset}> Retry </Button> </div> ); }
Data feed degradation
Not all failures are rendering errors. A more common scenario is a data feed dropping or returning stale data. This needs a different strategy.
Connection state indicators
Every data-dependent panel should show its connection state:
function ConnectionIndicator({ state }: { state: ConnectionState }) { const config = { connected: { color: "bg-green-500", label: "Live" }, reconnecting: { color: "bg-yellow-500", label: "Reconnecting" }, disconnected: { color: "bg-red-500", label: "Disconnected" }, stale: { color: "bg-orange-500", label: "Data may be stale" }, }; const { color, label } = config[state]; return ( <div className="flex items-center gap-1.5"> <div className={`h-2 w-2 rounded-full ${color}`} /> <span className="text-xs text-muted-foreground">{label}</span> </div> ); }
Stale data detection
If no updates arrive for a configurable period, mark the data as stale:
function useStalenessDetector( lastUpdateTime: number, thresholdMs: number = 5000 ) { const [isStale, setIsStale] = useState(false); useEffect(() => { const interval = setInterval(() => { setIsStale(Date.now() - lastUpdateTime > thresholdMs); }, 1000); return () => clearInterval(interval); }, [lastUpdateTime, thresholdMs]); return isStale; }
When data is stale, dim the affected panel or overlay a warning. Do not hide the data entirely - stale prices are better than no prices when a trader needs to make a decision.
Partial feed failure
If the order book feed drops but the trade feed is still alive, show what you have. Grey out the order book and display a reconnection indicator, but keep the trade history and chart updating normally.
The worst response to a partial failure is to show nothing. Traders would rather see degraded data with a clear warning than a blank screen.
Recovery patterns
Automatic retry with backoff
When a panel fails to render, automatic retry can resolve transient issues:
function useAutoRetry(errorBoundaryReset: () => void, maxRetries: number = 3) { const retryCount = useRef(0); useEffect(() => { if (retryCount.current < maxRetries) { const delay = Math.pow(2, retryCount.current) * 1000; const timer = setTimeout(() => { retryCount.current++; errorBoundaryReset(); }, delay); return () => clearTimeout(timer); } }, [errorBoundaryReset, maxRetries]); }
After exhausting retries, show the manual retry button. If a component is failing repeatedly, there is likely a code bug rather than a transient issue.
State reconciliation after reconnection
When a WebSocket reconnects after a gap, request a fresh snapshot rather than relying on the delta stream to fill in the missing updates. This is the only reliable way to ensure the displayed data matches the server state.
Error reporting
Every caught error should be reported so you can fix the underlying issue:
function handlePanelError(error: Error, errorInfo: React.ErrorInfo) { reportError({ message: error.message, stack: error.stack, componentStack: errorInfo.componentStack, timestamp: Date.now(), activeInstrument: getCurrentInstrument(), connectionState: getConnectionState(), }); }
Include the trading context - which instrument was active, what the connection state was, and what the user was doing. A stack trace alone is often not enough to reproduce trading UI bugs.
Testing degradation
Write explicit tests for degraded states:
it("continues showing orders when the chart throws", () => { const BrokenChart = () => { throw new Error("Chart failed"); }; render(<TradingWorkspace chartComponent={BrokenChart} orders={mockOrders} />); // Chart shows error state expect(screen.getByText("Chart failed to load")).toBeInTheDocument(); // Orders are still visible expect(screen.getByText("BTC-USD")).toBeInTheDocument(); expect(screen.getByText("Limit")).toBeInTheDocument(); });
If you do not test degradation explicitly, you will not know it works until a production failure teaches you otherwise.
