Building a Trading Dashboard with React & TypeScript
In the previous tutorial, you built a trading dashboard using vanilla HTML, CSS, and JavaScript. Now, let's upgrade that foundation into a production-ready React application with TypeScript, custom hooks, and modular component architecture. You'll learn how to structure a real-time trading interface using modern React patterns while maintaining the same powerful Surflux integration.
What You'll Build
A fully functional React trading dashboard with:
- Real-time candlestick price charts with timeframe switching
- Live order book with depth visualization
- Streaming trade feed with color-coded indicators
- Market depth chart showing cumulative liquidity
- Custom hooks for SSE connection management
- TypeScript for type safety
Prerequisites
- React 18+ and TypeScript experience
- Understanding of React hooks and component lifecycle
- Familiarity with trading concepts
- A Surflux API key
- Node.js 18+ installed
This tutorial demonstrates API integration patterns. For production, proxy all Surflux requests through your backend to protect your API key and implement proper authentication, rate limiting, and caching.
Project Setup
Create a new React + TypeScript + Vite project:
npm create vite@latest deepbook-dashboard -- --template react-ts
cd deepbook-dashboard
npm install
Install dependencies:
npm install lightweight-charts-react-wrapper
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
Configure Tailwind CSS in tailwind.config.js:
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
'dark': '#0B0E11',
'dark-light': '#13171D',
'gray-border': '#1E2329',
'gray-lighter': '#2B3139',
'text-primary': '#EAECEF',
'text-secondary': '#848E9C',
'green': '#0ECB81',
'red': '#F6465D',
'yellow': '#F0B90B',
}
}
},
plugins: [],
}
Add Tailwind directives to src/index.css:
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
Type Definitions
Create src/types.ts for type safety:
export interface Pool {
pool_id: string;
pool_name: string;
base_asset_id: string;
base_asset_decimals: number;
base_asset_symbol: string;
base_asset_name: string;
quote_asset_id: string;
quote_asset_decimals: number;
quote_asset_symbol: string;
quote_asset_name: string;
min_size: number;
lot_size: number;
tick_size: number;
}
export interface Trade {
price: number;
base_quantity: number;
quote_quantity: number;
taker_is_bid: boolean;
checkpoint_timestamp_ms: number;
digest?: string;
checkpoint?: number;
}
export interface OrderLevel {
price: number;
total_quantity: number;
order_count: number;
}
export interface OrderBook {
pool_id: string;
bids: OrderLevel[];
asks: OrderLevel[];
}
export interface Candle {
timestamp: string;
open: string;
high: string;
low: string;
close: string;
volume: string;
}
export interface SSEEvent {
type: 'deepbook_live_trades' | 'deepbook_order_book_depth';
timestamp_ms: number;
checkpoint_id: number;
tx_hash: string;
data: Trade | OrderBook;
}
export type Timeframe = '1m' | '5m' | '15m' | '1h' | '4h' | '1d';
API Service
Create src/services/api.ts for API interactions:
import { Pool, OrderBook, Trade, Candle } from '../types';
// Surflux API endpoints
const API_URL = 'https://api.surflux.dev'; // REST API for data queries
const STREAM_URL = 'https://flux.surflux.dev'; // SSE for real-time updates
async function apiRequest<T>(endpoint: string, params: Record<string, string | number> = {}): Promise<T> {
const url = new URL(API_URL + endpoint);
Object.entries(params).forEach(([key, value]) => {
url.searchParams.append(key, String(value));
});
const response = await fetch(url.toString());
if (!response.ok) {
throw new Error(`API Error: ${response.statusText}`);
}
return response.json();
}
export async function fetchPools(apiKey: string): Promise<Pool[]> {
return apiRequest<Pool[]>('/deepbook/get_pools', { 'api-key': apiKey });
}
export async function fetchOHLCV(
apiKey: string,
poolName: string,
timeframe: string,
from: number,
to: number
): Promise<Candle[]> {
return apiRequest<Candle[]>(`/deepbook/${poolName}/ohlcv/${timeframe}`, {
'api-key': apiKey,
from,
to,
limit: 500
});
}
export async function fetchOrderBook(
apiKey: string,
poolName: string,
limit: number = 15
): Promise<OrderBook> {
return apiRequest<OrderBook>(`/deepbook/${poolName}/order-book-depth`, {
'api-key': apiKey,
limit
});
}
export async function fetchTrades(
apiKey: string,
poolName: string
): Promise<Trade[]> {
return apiRequest<Trade[]>(`/deepbook/${poolName}/trades`, {
'api-key': apiKey
});
}
// Export STREAM_URL for SSE connections
export { STREAM_URL };
Custom SSE Hook
Create src/hooks/useSSE.ts for SSE connection management:
import { useEffect, useRef, useState } from 'react';
import { SSEEvent } from '../types';
import { STREAM_URL } from '../services/api';
interface UseSSEOptions {
apiKey: string;
poolName: string;
onMessage: (event: SSEEvent) => void;
onError?: (error: Event) => void;
}
export function useSSE({ apiKey, poolName, onMessage, onError }: UseSSEOptions) {
const [connected, setConnected] = useState(false);
const eventSourceRef = useRef<EventSource | null>(null);
useEffect(() => {
if (!apiKey || !poolName) return;
const url = new URL(`${STREAM_URL}/deepbook/${poolName}/live-trades`);
url.searchParams.append('api-key', apiKey);
const eventSource = new EventSource(url.toString());
eventSourceRef.current = eventSource;
eventSource.onopen = () => {
console.log('SSE connection established');
setConnected(true);
};
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as SSEEvent;
onMessage(data);
} catch (error) {
console.error('SSE parse error:', error);
}
};
eventSource.onerror = (error) => {
console.error('SSE connection error:', error);
setConnected(false);
onError?.(error);
};
return () => {
eventSource.close();
setConnected(false);
};
}, [apiKey, poolName, onMessage, onError]);
return { connected };
}
Price Chart Component
Create src/components/PriceChart.tsx:
import { useEffect, useRef } from 'react';
import { createChart, IChartApi, ISeriesApi, CandlestickData } from 'lightweight-charts';
import { Timeframe } from '../types';
interface PriceChartProps {
data: CandlestickData[];
timeframe: Timeframe;
onTimeframeChange: (timeframe: Timeframe) => void;
}
const timeframes: Timeframe[] = ['1m', '5m', '15m', '1h', '4h', '1d'];
export function PriceChart({ data, timeframe, onTimeframeChange }: PriceChartProps) {
const chartContainerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<IChartApi | null>(null);
const seriesRef = useRef<ISeriesApi<'Candlestick'> | null>(null);
useEffect(() => {
if (!chartContainerRef.current) return;
const chart = createChart(chartContainerRef.current, {
layout: {
background: { color: '#13171D' },
textColor: '#848E9C'
},
grid: {
vertLines: { color: '#1E2329' },
horzLines: { color: '#1E2329' }
},
width: chartContainerRef.current.clientWidth,
height: 400,
timeScale: {
timeVisible: true,
borderColor: '#1E2329'
},
rightPriceScale: { borderColor: '#1E2329' },
handleScroll: {
mouseWheel: true,
pressedMouseMove: true
}
});
const series = chart.addCandlestickSeries({
upColor: '#0ECB81',
downColor: '#F6465D',
borderVisible: false,
wickUpColor: '#0ECB81',
wickDownColor: '#F6465D',
});
chartRef.current = chart;
seriesRef.current = series;
const handleResize = () => {
if (chartContainerRef.current) {
chart.applyOptions({
width: chartContainerRef.current.clientWidth
});
}
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
chart.remove();
};
}, []);
useEffect(() => {
if (seriesRef.current && data.length > 0) {
seriesRef.current.setData(data);
chartRef.current?.timeScale().fitContent();
}
}, [data]);
return (
<section className="bg-dark-light h-full">
<div className="px-4 py-3 border-b border-gray-border flex items-center justify-between">
<h2 className="text-sm font-semibold">Price Chart</h2>
<div className="flex gap-1">
{timeframes.map((tf) => (
<button
key={tf}
onClick={() => onTimeframeChange(tf)}
className={`px-3 py-1 rounded text-xs font-semibold transition-colors ${
timeframe === tf
? 'bg-yellow text-dark'
: 'bg-gray-border text-text-secondary hover:text-text-primary'
}`}
>
{tf}
</button>
))}
</div>
</div>
<div ref={chartContainerRef} className="w-full" />
</section>
);
}
Order Book Component
Create src/components/OrderBook.tsx:
import { OrderBook as OrderBookType, OrderLevel } from '../types';
interface OrderBookProps {
orderBook: OrderBookType | null;
baseDecimals: number;
quoteDecimals: number;
}
export function OrderBook({ orderBook, baseDecimals, quoteDecimals }: OrderBookProps) {
if (!orderBook) {
return (
<section className="bg-dark-light h-full">
<div className="px-4 py-3 border-b border-gray-border">
<h2 className="text-sm font-semibold">Order Book</h2>
</div>
<div className="flex items-center justify-center h-64 text-text-secondary">
No data
</div>
</section>
);
}
const maxAmount = Math.max(
...orderBook.asks.map(a => a.total_quantity),
...orderBook.bids.map(b => b.total_quantity)
);
const renderOrderRow = (order: OrderLevel, isBid: boolean) => {
const price = order.price / Math.pow(10, quoteDecimals);
const amount = order.total_quantity / Math.pow(10, baseDecimals);
const total = price * amount;
const depth = (order.total_quantity / maxAmount) * 100;
const colorClass = isBid ? 'text-green' : 'text-red';
const bgClass = isBid ? 'bg-green/10' : 'bg-red/10';
return (
<div key={order.price} className="relative grid grid-cols-3 gap-2 px-2 py-1 text-xs font-mono">
<div className={`absolute inset-y-0 right-0 ${bgClass} opacity-60`} style={{ width: `${depth}%` }} />
<div className={`relative ${colorClass}`}>{price.toFixed(4)}</div>
<div className="relative text-right">{amount.toFixed(2)}</div>
<div className="relative text-right">{total.toFixed(2)}</div>
</div>
);
};
const spread = orderBook.asks.length > 0 && orderBook.bids.length > 0
? (orderBook.asks[0].price - orderBook.bids[0].price) / Math.pow(10, quoteDecimals)
: 0;
const spreadPercent = spread > 0
? (spread / (orderBook.asks[0].price / Math.pow(10, quoteDecimals))) * 100
: 0;
return (
<section className="bg-dark-light h-full overflow-hidden flex flex-col">
<div className="px-4 py-3 border-b border-gray-border">
<h2 className="text-sm font-semibold">Order Book</h2>
</div>
<div className="flex-1 overflow-y-auto">
<div className="grid grid-cols-3 gap-2 px-2 py-1 text-text-secondary text-xs uppercase border-b border-gray-border sticky top-0 bg-dark-light">
<div>Price</div>
<div className="text-right">Amount</div>
<div className="text-right">Total</div>
</div>
<div className="pb-2">
{orderBook.asks.slice(0, 15).reverse().map(ask => renderOrderRow(ask, false))}
</div>
<div className="bg-gray-border px-3 py-2 text-center text-yellow text-sm font-semibold">
{spread.toFixed(4)} ({spreadPercent.toFixed(2)}%)
</div>
<div className="pt-2">
{orderBook.bids.slice(0, 15).map(bid => renderOrderRow(bid, true))}
</div>
</div>
</section>
);
}
Recent Trades Component
Create src/components/RecentTrades.tsx:
import { Trade } from '../types';
interface RecentTradesProps {
trades: Trade[];
baseDecimals: number;
quoteDecimals: number;
}
export function RecentTrades({ trades, baseDecimals, quoteDecimals }: RecentTradesProps) {
return (
<section className="bg-dark-light h-full overflow-hidden flex flex-col">
<div className="px-4 py-3 border-b border-gray-border">
<h2 className="text-sm font-semibold">Recent Trades</h2>
</div>
<div className="flex-1 overflow-y-auto">
<div className="grid grid-cols-3 gap-2 px-2 py-2 text-text-secondary text-xs uppercase border-b border-gray-border sticky top-0 bg-dark-light">
<div>Price</div>
<div className="text-right">Amount</div>
<div className="text-right">Time</div>
</div>
<div>
{trades.map((trade, index) => {
const price = trade.price / Math.pow(10, quoteDecimals);
const amount = trade.base_quantity / Math.pow(10, baseDecimals);
const time = new Date(trade.checkpoint_timestamp_ms).toLocaleTimeString();
const isBuy = trade.taker_is_bid;
const priceClass = isBuy ? 'text-green' : 'text-red';
return (
<div
key={`${trade.checkpoint_timestamp_ms}-${index}`}
className="grid grid-cols-3 gap-2 px-2 py-1.5 text-xs font-mono border-b border-gray-border/50 hover:bg-gray-border/30"
>
<div className={priceClass}>{price.toFixed(4)}</div>
<div className="text-right">{amount.toFixed(2)}</div>
<div className="text-right text-text-secondary text-xs">{time}</div>
</div>
);
})}
</div>
</div>
</section>
);
}
Market Depth Component
Create src/components/MarketDepth.tsx:
import { OrderBook } from '../types';
interface MarketDepthProps {
orderBook: OrderBook | null;
baseDecimals: number;
quoteDecimals: number;
}
export function MarketDepth({ orderBook, baseDecimals, quoteDecimals }: MarketDepthProps) {
if (!orderBook || orderBook.bids.length === 0 || orderBook.asks.length === 0) {
return (
<section className="bg-dark-light h-full">
<div className="px-4 py-3 border-b border-gray-border">
<h2 className="text-sm font-semibold">Market Depth</h2>
</div>
<div className="flex items-center justify-center h-64 text-text-secondary">
No data
</div>
</section>
);
}
const maxLevels = 10;
const bids = orderBook.bids.slice(0, maxLevels);
const asks = orderBook.asks.slice(0, maxLevels);
let bidCumulative = 0;
let askCumulative = 0;
const bidAmounts = bids.map(bid => {
const amount = bid.total_quantity / Math.pow(10, baseDecimals);
bidCumulative += amount;
return bidCumulative;
});
const askAmounts = asks.map(ask => {
const amount = ask.total_quantity / Math.pow(10, baseDecimals);
askCumulative += amount;
return askCumulative;
});
const maxAmount = Math.max(
bidAmounts[bidAmounts.length - 1] || 0,
askAmounts[askAmounts.length - 1] || 0
);
const allPrices = [
...bids.map(b => b.price / Math.pow(10, quoteDecimals)),
...asks.map(a => a.price / Math.pow(10, quoteDecimals))
];
const minPrice = Math.min(...allPrices);
const maxPrice = Math.max(...allPrices);
const midPrice = (minPrice + maxPrice) / 2;
const strategicPrices = [
minPrice,
(minPrice + midPrice) / 2,
midPrice,
(midPrice + maxPrice) / 2,
maxPrice
];
return (
<section className="bg-dark-light h-full flex flex-col">
<div className="px-4 py-3 border-b border-gray-border">
<h2 className="text-sm font-semibold">Market Depth</h2>
</div>
<div className="flex-1 flex flex-col p-4">
<div className="flex justify-between text-xs text-text-secondary mb-2">
<span>Bids</span>
<span>Asks</span>
</div>
<div className="flex-1 flex">
<div className="flex flex-col justify-between text-xs text-text-secondary pr-2 py-1">
{[4, 3, 2, 1, 0].map(i => {
const value = (maxAmount * (i + 1)) / 5;
return (
<div key={i} className="text-right">
{value > 1000 ? `${(value / 1000).toFixed(1)}k` : value.toFixed(0)}
</div>
);
})}
</div>
<div className="flex-1 flex flex-col">
<div className="flex-1 flex items-end gap-px">
{bids.reverse().map((bid, i) => {
const height = (bidAmounts[bids.length - 1 - i] / maxAmount) * 100;
const price = bid.price / Math.pow(10, quoteDecimals);
return (
<div
key={bid.price}
className="flex-1 bg-green/40 hover:bg-green/60 transition-colors cursor-pointer"
style={{ height: `${height}%` }}
title={`Price: ${price.toFixed(4)}, Amount: ${bidAmounts[bids.length - 1 - i].toFixed(2)}`}
/>
);
})}
{asks.map((ask, i) => {
const height = (askAmounts[i] / maxAmount) * 100;
const price = ask.price / Math.pow(10, quoteDecimals);
return (
<div
key={ask.price}
className="flex-1 bg-red/40 hover:bg-red/60 transition-colors cursor-pointer"
style={{ height: `${height}%` }}
title={`Price: ${price.toFixed(4)}, Amount: ${askAmounts[i].toFixed(2)}`}
/>
);
})}
</div>
<div className="flex justify-between text-xs text-text-secondary mt-1 px-1">
{strategicPrices.map((price, i) => (
<div key={i} className="text-center">{price.toFixed(4)}</div>
))}
</div>
</div>
</div>
<div className="flex justify-between text-xs text-text-secondary mt-2">
<span>Total: {bidCumulative.toFixed(2)}</span>
<span>Total: {askCumulative.toFixed(2)}</span>
</div>
</div>
</section>
);
}
Main Dashboard Component
Create src/App.tsx:
import { useState, useCallback, useEffect } from 'react';
import { CandlestickData } from 'lightweight-charts';
import { PriceChart } from './components/PriceChart';
import { OrderBook } from './components/OrderBook';
import { RecentTrades } from './components/RecentTrades';
import { MarketDepth } from './components/MarketDepth';
import { useSSE } from './hooks/useSSE';
import { fetchPools, fetchOHLCV, fetchOrderBook, fetchTrades } from './services/api';
import { Pool, OrderBook as OrderBookType, Trade, Timeframe, SSEEvent } from './types';
import './index.css';
function App() {
const [apiKey, setApiKey] = useState('test_api_key');
const [poolName, setPoolName] = useState('SUI_USDC');
const [poolData, setPoolData] = useState<Pool | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [timeframe, setTimeframe] = useState<Timeframe>('1m');
const [candleData, setCandleData] = useState<CandlestickData[]>([]);
const [orderBook, setOrderBook] = useState<OrderBookType | null>(null);
const [trades, setTrades] = useState<Trade[]>([]);
const [currentPrice, setCurrentPrice] = useState<number | null>(null);
const [priceChange, setPriceChange] = useState<number>(0);
const handleSSEMessage = useCallback((event: SSEEvent) => {
if (event.type === 'deepbook_order_book_depth') {
setOrderBook(event.data as OrderBookType);
} else if (event.type === 'deepbook_live_trades') {
const trade = event.data as Trade;
setTrades(prev => [trade, ...prev.slice(0, 49)]);
if (poolData) {
const price = trade.price / Math.pow(10, poolData.quote_asset_decimals);
setCurrentPrice(price);
}
}
}, [poolData]);
const { connected } = useSSE({
apiKey: isConnected ? apiKey : '',
poolName: isConnected ? poolName : '',
onMessage: handleSSEMessage
});
const loadInitialData = async () => {
if (!apiKey || !poolName) {
alert('Please enter API key and pool name');
return;
}
try {
// Fetch pool info
const pools = await fetchPools(apiKey);
const pool = pools.find(p => p.pool_name === poolName);
if (!pool) {
throw new Error(`Pool ${poolName} not found`);
}
setPoolData(pool);
// Fetch OHLCV data
const to = Math.floor(Date.now() / 1000);
const from = to - 24 * 60 * 60;
const ohlcv = await fetchOHLCV(apiKey, poolName, timeframe, from, to);
const candles: CandlestickData[] = ohlcv.map(candle => ({
time: Math.floor(new Date(candle.timestamp).getTime() / 1000) as any,
open: parseFloat(candle.open) / Math.pow(10, pool.quote_asset_decimals),
high: parseFloat(candle.high) / Math.pow(10, pool.quote_asset_decimals),
low: parseFloat(candle.low) / Math.pow(10, pool.quote_asset_decimals),
close: parseFloat(candle.close) / Math.pow(10, pool.quote_asset_decimals),
})).reverse();
setCandleData(candles);
if (candles.length > 0) {
const latest = candles[candles.length - 1];
const first = candles[0];
setCurrentPrice(latest.close);
setPriceChange(((latest.close - first.close) / first.close) * 100);
}
// Fetch order book
const orderBookData = await fetchOrderBook(apiKey, poolName);
setOrderBook(orderBookData);
// Fetch initial trades
const tradesData = await fetchTrades(apiKey, poolName);
setTrades(tradesData.reverse());
setIsConnected(true);
} catch (error) {
alert(`Failed to connect: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
const handleTimeframeChange = async (newTimeframe: Timeframe) => {
setTimeframe(newTimeframe);
if (!apiKey || !poolName || !poolData) return;
try {
const to = Math.floor(Date.now() / 1000);
const from = to - 24 * 60 * 60;
const ohlcv = await fetchOHLCV(apiKey, poolName, newTimeframe, from, to);
const candles: CandlestickData[] = ohlcv.map(candle => ({
time: Math.floor(new Date(candle.timestamp).getTime() / 1000) as any,
open: parseFloat(candle.open) / Math.pow(10, poolData.quote_asset_decimals),
high: parseFloat(candle.high) / Math.pow(10, poolData.quote_asset_decimals),
low: parseFloat(candle.low) / Math.pow(10, poolData.quote_asset_decimals),
close: parseFloat(candle.close) / Math.pow(10, poolData.quote_asset_decimals),
})).reverse();
setCandleData(candles);
} catch (error) {
console.error('Failed to fetch OHLCV:', error);
}
};
const isPricePositive = priceChange >= 0;
return (
<div className="bg-dark text-text-primary min-h-screen flex flex-col">
<header className="bg-dark-light border-b border-gray-border px-6 py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-5">
<h1 className="text-yellow text-xl font-bold">DeepBook Dashboard</h1>
<input
type="text"
value={poolName}
onChange={(e) => setPoolName(e.target.value)}
placeholder="Pool Name"
className="bg-gray-border text-text-primary px-3 py-1.5 rounded text-sm w-32 focus:outline-none"
disabled={isConnected}
/>
{currentPrice !== null && (
<div className="flex items-center gap-3">
<div className={`text-xl font-semibold ${isPricePositive ? 'text-green' : 'text-red'}`}>
{currentPrice.toFixed(4)}
</div>
<div className={`text-sm px-2 py-1 rounded ${isPricePositive ? 'bg-green/10 text-green' : 'bg-red/10 text-red'}`}>
{isPricePositive ? '+' : ''}{priceChange.toFixed(2)}%
</div>
</div>
)}
</div>
<div className="flex items-center gap-3">
<input
type="text"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="API Key"
className="bg-gray-border text-text-primary px-3 py-2 rounded text-sm w-44 focus:outline-none"
disabled={isConnected}
/>
<div className={`w-2 h-2 rounded-full ${connected ? 'bg-green' : 'bg-text-secondary'}`} />
<button
onClick={loadInitialData}
disabled={isConnected}
className="bg-yellow text-dark px-4 py-2 rounded font-semibold text-sm hover:bg-yellow/90 disabled:bg-gray-lighter disabled:text-text-secondary"
>
{isConnected ? 'Connected' : 'Connect'}
</button>
</div>
</div>
</header>
<main className="flex-1 grid lg:grid-cols-[1fr_320px_300px] lg:grid-rows-[500px_1fr] gap-px bg-gray-border p-px">
<div className="lg:col-span-1 lg:row-span-1">
<PriceChart
data={candleData}
timeframe={timeframe}
onTimeframeChange={handleTimeframeChange}
/>
</div>
<div className="lg:col-span-1 lg:row-span-2">
<OrderBook
orderBook={orderBook}
baseDecimals={poolData?.base_asset_decimals || 9}
quoteDecimals={poolData?.quote_asset_decimals || 6}
/>
</div>
<div className="lg:col-span-1 lg:row-span-2">
<RecentTrades
trades={trades}
baseDecimals={poolData?.base_asset_decimals || 9}
quoteDecimals={poolData?.quote_asset_decimals || 6}
/>
</div>
<div className="lg:col-span-1 lg:row-span-1">
<MarketDepth
orderBook={orderBook}
baseDecimals={poolData?.base_asset_decimals || 9}
quoteDecimals={poolData?.quote_asset_decimals || 6}
/>
</div>
</main>
</div>
);
}
export default App;
Running the Application
Start the development server:
npm run dev
Open http://localhost:5173 in your browser.
Key React Patterns
Custom Hooks
The useSSE hook encapsulates SSE connection logic with proper cleanup:
useEffect(() => {
const eventSource = new EventSource(url);
// Event handlers...
return () => {
eventSource.close(); // Cleanup on unmount
};
}, [apiKey, poolName]);
State Management
Use useState for component state and useCallback for memoized event handlers:
const handleSSEMessage = useCallback((event: SSEEvent) => {
// Process events without recreating handler on every render
}, [poolData]);
Component Composition
Break down the dashboard into focused components:
PriceChart— Chart rendering and timeframe selectionOrderBook— Bid/ask display with depth visualizationRecentTrades— Trade list with color codingMarketDepth— Cumulative liquidity chart
TypeScript Integration
Strong typing prevents runtime errors:
interface OrderBookProps {
orderBook: OrderBookType | null;
baseDecimals: number;
quoteDecimals: number;
}
Production Considerations
Backend Proxy
Create an Express backend to proxy Surflux requests:
// backend/server.ts
import express from 'express';
import cors from 'cors';
const app = express();
app.use(cors());
const SURFLUX_API_KEY = process.env.SURFLUX_API_KEY;
app.get('/api/pools', async (req, res) => {
const response = await fetch(
`https://api.surflux.dev/deepbook/get_pools?api-key=${SURFLUX_API_KEY}`
);
res.json(await response.json());
});
// Add other endpoints...
app.listen(3001);
Update frontend to use backend:
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
Performance Optimization
- Use
React.memofor expensive components - Implement virtualization for large trade lists (react-window)
- Debounce rapid state updates
- Cache pool metadata in localStorage
Error Boundaries
Add error boundaries for graceful error handling:
class ErrorBoundary extends React.Component {
componentDidCatch(error, errorInfo) {
console.error('Dashboard error:', error, errorInfo);
}
render() {
return this.props.children;
}
}
Testing
Write tests for critical functionality:
import { render, screen } from '@testing-library/react';
import { OrderBook } from './OrderBook';
test('renders order book with bids and asks', () => {
const mockOrderBook = {
pool_id: '0x123',
bids: [{ price: 1000000, total_quantity: 500000000, order_count: 1 }],
asks: [{ price: 1010000, total_quantity: 500000000, order_count: 1 }]
};
render(<OrderBook orderBook={mockOrderBook} baseDecimals={9} quoteDecimals={6} />);
expect(screen.getByText(/Order Book/i)).toBeInTheDocument();
});
Building for Production
npm run build
Deploy the dist folder to your hosting provider (Vercel, Netlify, etc.).
Next Steps
- Add order placement functionality
- Implement WebSocket fallback for SSE
- Create responsive mobile layout
- Add technical indicators overlay
- Build portfolio tracking
- Integrate wallet connections
Related Documentation
- Live Trades — SSE event structure
- Order Book Depth — Order book details
- Building a Trading Dashboard with HTML, CSS & JavaScript — Foundation tutorial
- Consuming SSE Events — SSE basics