Complete the part 4 series by building a real-time Hyperliquid liquidation dashboard with React.
Welcome to the final part of our 4-part series! In Part 3, we built a powerful Express.js API that transforms raw JSON files into queryable endpoints. Now, we'll complete the process by creating a dashboard that visualizes this crypto liquidation data in real time.
In this tutorial, you'll create a React dashboard with:
Live statistics overview showing total liquidations, losses, and system metrics
Interactive analytics charts for top losses and liquidation distribution
Real-time risk monitoring with positions nearing liquidation
Estimated time: 20-25 minutes
Before starting, verify you have:
Part 3 API running - Open http://localhost:3001/api/stats in your Postman. You should see JSON data. If not, go back and complete Part 3 first.
Basic React knowledge - Understand components, props, and the useState hook.
Two terminal windows - One for your API server, one for React development.
Installation
Open a new terminal window (keep your API server running in the other one) and run:
npx create-react-app liquidation-dapp
cd hyperliquid-dappInstall Charting Libraries
Now add the visualization libraries:
npm install recharts lucide-reactRun the development server to see if everything is working using:
npm startYour browser should open to http://localhost:3000, showing the default React page. Keep this running while we build the dashboard.
Before getting into code, here's our system flow:

Now let's start building the actual dApp. We'll break this into digestible sections.
Understanding the Component Structure
Our App.js component has three main responsibilities:
State Management: Track all data from the API.
Data Fetching: Pull updates from 7 different endpoints.
Rendering: Displaying data in an organized, visual way.
Imports & Create Storage
Open src/App.js and delete everything. Start fresh with these imports:
import React, { useState, useEffect } from 'react';
import { BarChart, Bar, PieChart, Pie, Cell, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import { AlertCircle, TrendingDown, DollarSign, Activity, Database, Clock } from 'lucide-react';
import './App.css';
const API_URL = 'http://localhost:3001/api';
function App() {
const [stats, setStats] = useState(null);
const [recentLiquidations, setRecentLiquidations] = useState([]);
const [latestPositions, setLatestPositions] = useState([]);
const [atRiskPositions, setAtRiskPositions] = useState([]);
const [topLosers, setTopLosers] = useState([]);
const [byCoin, setByCoin] = useState([]);
const [storageInfo, setStorageInfo] = useState(null);
const [loading, setLoading] = useState(true);
const [lastUpdate, setLastUpdate] = useState(new Date());This imports React hooks for state management, charting components from Recharts, icons from Lucide, and points to our API from Part 3 to store state types.
Data Fetching Engine
const fetchData = async () => {
try {
const [statsRes, liqRes, posRes, riskRes, losersRes, coinRes, storageRes] = await Promise.all([
fetch(`${API_URL}/stats`),
fetch(`${API_URL}/liquidations/recent?limit=20`),
fetch(`${API_URL}/positions/latest`),
fetch(`${API_URL}/positions/at-risk?threshold=10`),
fetch(`${API_URL}/analytics/top-losers?limit=10&days=7`),
fetch(`${API_URL}/analytics/by-coin?days=7`),
fetch(`${API_URL}/storage/info`)
]);
setStats(await statsRes.json());
setRecentLiquidations(await liqRes.json());
setLatestPositions(await posRes.json());
setAtRiskPositions(await riskRes.json());
setTopLosers(await losersRes.json());
setByCoin(await coinRes.json());
setStorageInfo(await storageRes.json());
setLastUpdate(new Date());
setLoading(false);
} catch (error) {
console.error('Error fetching data:', error);
setLoading(false);
}
};
useEffect(() => {
fetchData();
const interval = setInterval(fetchData, 30000);
return () => clearInterval(interval);
}, []);This fetches ALL your liquidation data from 7 API endpoints simultaneously, then automatically refreshes every 30 seconds.
Data Formatting
const formatAddress = (addr) => `${addr?.slice(0, 6)}...${addr?.slice(-4)}`;
const formatCurrency = (val) => `$${val?.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
const formatNumber = (val) => val?.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const COLORS = ['#ef4444', '#f97316', '#eab308', '#22c55e', '#3b82f6', '#8b5cf6', '#ec4899'];
if (loading) {
return (
<div className="loading-container">
<div className="loading-content">
<Activity className="loading-spinner" />
<p className="loading-text">Loading dashboard...</p>
</div>
</div>
);
}Prepares your data for display formats, wallet addresses, and money values. Shows a loading screen while data is being fetched.
Header & Stats Cards
return (
<div className="dashboard">
{/* Header */}
<div className="header">
<div className="header-top">
<h1 className="title">Hyperliquid Liquidation Monitor</h1>
<div className="last-update">
<Clock style={{ width: 16, height: 16 }} />
<span>Last update: {lastUpdate.toLocaleTimeString()}</span>
</div>
</div>
<p className="subtitle">Real-time monitoring and analytics for Hyperliquid perpetual positions</p>
</div>
{/* Stats Cards */}
<div className="stats-grid">
<div className="stat-card">
<div className="stat-header">
<h3>Total Events</h3>
<Activity style={{ width: 20, height: 20, color: '#3b82f6' }} />
</div>
<p className="stat-value">{stats?.total_events || 0}</p>
</div>
<div className="stat-card">
<div className="stat-header">
<h3>Liquidations</h3>
<AlertCircle style={{ width: 20, height: 20, color: '#ef4444' }} />
</div>
<p className="stat-value" style={{ color: '#ef4444' }}>{stats?.liquidations || 0}</p>
<p className="stat-detail">{stats?.high_confidence || 0} high confidence</p>
</div>
<div className="stat-card">
<div className="stat-header">
<h3>Total Loss</h3>
<TrendingDown style={{ width: 20, height: 20, color: '#f97316' }} />
</div>
<p className="stat-value" style={{ color: '#f97316' }}>
{formatCurrency(stats?.total_loss || 0)}
</p>
</div>
<div className="stat-card">
<div className="stat-header">
<h3>Stored Snapshots</h3>
<Database style={{ width: 20, height: 20, color: '#22c55e' }} />
</div>
<p className="stat-value">{storageInfo?.totalSnapshots || 0}</p>
<p className="stat-detail">{storageInfo?.files || 0} files</p>
</div>
</div>The header is sticky (we'll handle this in CSS), so users always see the update time, even when scrolling through data. It creates the top part of your dashboard, the title with update time, plus 4 cards showing key stats (total events, liquidations, losses, and storage info).
Charts & Data Tables
<div className="charts-grid">
{/* Top Losers Chart */}
<div className="chart-card">
<h2 className="chart-title">
<DollarSign style={{ width: 20, height: 20, color: '#ef4444' }} />
Top 10 Losses (7 Days)
</h2>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={topLosers.slice(0, 10)}>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
<XAxis dataKey="coin" stroke="#9ca3af" />
<YAxis stroke="#9ca3af" />
<Tooltip
contentStyle={{ backgroundColor: '#1f2937', border: '1px solid #374151' }}
formatter={(value) => formatCurrency(value)}
/>
<Bar dataKey="lossAmount" fill="#ef4444" />
</BarChart>
</ResponsiveContainer>
</div>
{/* Liquidations by Coin */}
<div className="chart-card">
<h2 className="chart-title">Liquidations by Asset</h2>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={byCoin.slice(0, 7)}
dataKey="count"
nameKey="coin"
cx="50%"
cy="50%"
outerRadius={100}
label={(entry) => `${entry.coin}: ${entry.count}`}
>
{byCoin.slice(0, 7).map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip contentStyle={{ backgroundColor: '#1f2937', border: '1px solid #374151' }} />
</PieChart>
</ResponsiveContainer>
</div>
</div>
{/* At Risk Positions */}
{atRiskPositions.length > 0 && (
<div className="risk-card">
<h2 className="risk-title">
<AlertCircle style={{ width: 20, height: 20 }} />
Positions at Risk ({atRiskPositions.length})
</h2>
<div className="table-container">
<table className="data-table">
<thead>
<tr>
<th>Wallet</th>
<th>Coin</th>
<th>Side</th>
<th>Size</th>
<th>Entry Price</th>
<th>Liq Price</th>
<th>Unrealized PnL</th>
</tr>
</thead>
<tbody>
{atRiskPositions.slice(0, 10).map((pos, idx) => (
<tr key={idx}>
<td className="mono-font">{formatAddress(pos.wallet)}</td>
<td className="bold-text">{pos.coin}</td>
<td>
<span className={pos.size > 0 ? 'badge-long' : 'badge-short'}>
{pos.size > 0 ? 'LONG' : 'SHORT'}
</span>
</td>
<td>{formatNumber(Math.abs(pos.size))}</td>
<td>{formatCurrency(pos.entryPrice)}</td>
<td>{pos.liquidationPrice ? formatCurrency(pos.liquidationPrice) : 'N/A'}</td>
<td className={pos.unrealizedPnl < 0 ? 'text-red' : 'text-green'}>
{formatCurrency(pos.unrealizedPnl)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Recent Liquidations */}
<div className="liquidations-card">
<h2 className="section-title">Recent Liquidations</h2>
<div className="liquidations-list">
{recentLiquidations.length === 0 ? (
<p className="empty-message">No liquidations detected yet</p>
) : (
recentLiquidations.map((liq, idx) => (
<div key={idx} className="liquidation-item">
<div className="liquidation-badges">
<span className={`badge-${liq.type.toLowerCase()}`}>{liq.type}</span>
<span className={`badge-confidence-${liq.confidence.toLowerCase()}`}>{liq.confidence}</span>
<span className="badge-source">{liq.source}</span>
</div>
<div className="liquidation-details">
<div className="detail-item">
<p className="detail-label">Wallet</p>
<p className="detail-value mono-font">{liq.wallet}</p>
</div>
<div className="detail-item">
<p className="detail-label">Position</p>
<p className="detail-value bold-text">{liq.coin} {liq.side}</p>
</div>
<div className="detail-item">
<p className="detail-label">Size</p>
<p className="detail-value">{formatNumber(liq.size)}</p>
</div>
<div className="detail-item">
<p className="detail-label">Loss</p>
<p className="detail-value text-red bold-text">{formatCurrency(liq.lossAmount)}</p>
</div>
</div>
<p className="liquidation-reason">{liq.reason}</p>
<p className="liquidation-time">{new Date(liq.timestamp).toLocaleString()}</p>
</div>
))
)}
</div>
</div>
{/* Current Positions */}
<div className="positions-card">
<h2 className="section-title">Current Positions</h2>
<div className="positions-list">
{latestPositions.map((snapshot, idx) => (
<div key={idx} className="position-snapshot">
<div className="snapshot-header">
<h3 className="mono-font bold-text">{formatAddress(snapshot.wallet)}</h3>
<span className="snapshot-time">{new Date(snapshot.timestamp).toLocaleString()}</span>
</div>
<div className="table-container">
<table className="data-table">
<thead>
<tr>
<th>Coin</th>
<th>Side</th>
<th>Size</th>
<th>Entry</th>
<th>Value</th>
<th>PnL</th>
</tr>
</thead>
<tbody>
{snapshot.positions.slice(0, 5).map((pos, pidx) => (
<tr key={pidx}>
<td className="bold-text">{pos.coin}</td>
<td>
<span className={pos.size > 0 ? 'badge-long' : 'badge-short'}>
{pos.size > 0 ? 'LONG' : 'SHORT'}
</span>
</td>
<td>{formatNumber(Math.abs(pos.size))}</td>
<td>{formatCurrency(pos.entryPrice)}</td>
<td>{formatCurrency(pos.positionValue)}</td>
<td className={pos.unrealizedPnl < 0 ? 'text-red bold-text' : 'text-green bold-text'}>
{formatCurrency(pos.unrealizedPnl)}
</td>
</tr>
))}
</tbody>
</table>
{snapshot.positions.length > 5 && (
<p className="more-positions">+ {snapshot.positions.length - 5} more positions</p>
)}
</div>
</div>
))}
</div>
</div>
</div>
);
}
export default App;This creates charts (a bar chart for top losses and a pie chart for coin distribution) and a table showing positions at risk of liquidation. This is where users see the actual trading data.
Create or open src/App.css in your project root. Delete any existing content and paste the CSS below. This stylesheet provides dark theme colors, responsive grid layouts that adapt to mobile, smooth animations for loading states, and color-coded badges for long/short positions.
Global Setup & Basics
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', sans-serif;
background: #111827;
color: white;
}
/* Loading State */
.loading-container {
min-height: 100vh;
background: #111827;
color: white;
display: flex;
align-items: center;
justify-content: center;
}
.loading-content {
text-align: center;
}
.loading-spinner {
width: 64px;
height: 64px;
margin: 0 auto 16px;
color: #3b82f6;
animation: spin 1s linear infinite;
}
.loading-text {
font-size: 20px;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Dashboard Container */
.dashboard {
min-height: 100vh;
background: #111827;
color: white;
padding: 24px;
}
/* Header */
.header {
margin-bottom: 32px;
}
.header-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
flex-wrap: wrap;
gap: 16px;
}
.title {
font-size: 36px;
font-weight: bold;
background: linear-gradient(to right, #3b82f6, #8b5cf6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin: 0;
}
.last-update {
display: flex;
align-items: center;
gap: 8px;
color: #9ca3af;
font-size: 14px;
}
.subtitle {
color: #9ca3af;
margin: 0;
}This set up the dashboard's foundation with a dark theme, loading screen, container layout, and the header with a cool gradient title.
Cards & Layouts
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 24px;
margin-bottom: 32px;
}
.stat-card {
background: #1f2937;
border-radius: 8px;
padding: 24px;
border: 1px solid #374151;
}
.stat-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.stat-header h3 {
color: #9ca3af;
font-size: 14px;
font-weight: 500;
margin: 0;
}
.stat-value {
font-size: 30px;
font-weight: bold;
margin: 8px 0;
}
.stat-detail {
font-size: 14px;
color: #9ca3af;
margin: 4px 0 0 0;
}
/* Charts Grid */
.charts-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 24px;
margin-bottom: 32px;
}
.chart-card {
background: #1f2937;
border-radius: 8px;
padding: 24px;
border: 1px solid #374151;
}
.chart-title {
font-size: 20px;
font-weight: bold;
margin: 0 0 16px 0;
display: flex;
align-items: center;
gap: 8px;
}
/* Risk Card */
.risk-card {
background: #1f2937;
border-radius: 8px;
padding: 24px;
border: 2px solid #dc2626;
margin-bottom: 32px;
}
.risk-title {
font-size: 20px;
font-weight: bold;
margin: 0 0 16px 0;
color: #ef4444;
display: flex;
align-items: center;
gap: 8px;
}
/* Tables */
.table-container {
overflow-x: auto;
}
.data-table {
width: 100%;
font-size: 14px;
border-collapse: collapse;
}
.data-table th {
text-align: left;
padding-bottom: 8px;
border-bottom: 1px solid #374151;
font-weight: 500;
color: #9ca3af;
}
.data-table td {
padding: 8px 8px 8px 0;
border-bottom: 1px solid #374151;
}
.data-table tbody tr:last-child td {
border-bottom: none;
}
/* Badges */
.badge-long {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
background: #065f46;
color: #6ee7b7;
display: inline-block;
}
.badge-short {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
background: #7f1d1d;
color: #fca5a5;
display: inline-block;
}
.badge-liquidation {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
background: #7f1d1d;
color: #fca5a5;
display: inline-block;
}
.badge-margin_call {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
background: #78350f;
color: #fdba74;
display: inline-block;
}
.badge-position_closed {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
background: #1e3a8a;
color: #93c5fd;
display: inline-block;
}
.badge-confidence-high {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
background: #7f1d1d;
color: #fca5a5;
display: inline-block;
}
.badge-confidence-medium {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
background: #713f12;
color: #fde047;
display: inline-block;
}
.badge-confidence-low {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
background: #374151;
color: #d1d5db;
display: inline-block;
}
.badge-source {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
color: #9ca3af;
display: inline-block;
}Creates all the responsive layouts with 4 stat cards, 2 chart containers, a risk table, and data tables with proper spacing and borders.
Components & Utilities
.liquidations-card {
background: #1f2937;
border-radius: 8px;
padding: 24px;
border: 1px solid #374151;
margin-bottom: 32px;
}
.section-title {
font-size: 20px;
font-weight: bold;
margin: 0 0 16px 0;
}
.liquidations-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.liquidation-item {
background: #111827;
border-radius: 8px;
padding: 16px;
border: 1px solid #374151;
}
.liquidation-badges {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
flex-wrap: wrap;
}
.liquidation-details {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 16px;
font-size: 14px;
margin-bottom: 8px;
}
.detail-item {
min-width: 0;
}
.detail-label {
color: #9ca3af;
margin: 0 0 4px 0;
font-size: 13px;
}
.detail-value {
margin: 0;
word-break: break-word;
}
.liquidation-reason {
font-size: 12px;
color: #9ca3af;
margin: 8px 0 4px 0;
}
.liquidation-time {
font-size: 12px;
color: #6b7280;
margin: 4px 0 0 0;
}
.empty-message {
color: #9ca3af;
text-align: center;
padding: 32px 0;
margin: 0;
}
/* Positions Section */
.positions-card {
background: #1f2937;
border-radius: 8px;
padding: 24px;
border: 1px solid #374151;
}
.positions-list {
display: flex;
flex-direction: column;
gap: 24px;
}
.position-snapshot {
background: #111827;
border-radius: 8px;
padding: 16px;
border: 1px solid #374151;
}
.snapshot-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
flex-wrap: wrap;
gap: 8px;
}
.snapshot-header h3 {
font-size: 18px;
margin: 0;
}
.snapshot-time {
font-size: 12px;
color: #9ca3af;
}
.more-positions {
font-size: 12px;
color: #9ca3af;
margin: 8px 0 0 0;
}
/* Utility Classes */
.mono-font {
font-family: 'Courier New', monospace;
font-size: 12px;
}
.bold-text {
font-weight: bold;
}
.text-red {
color: #ef4444;
}
.text-green {
color: #22c55e;
}
/* Responsive */
@media (max-width: 768px) {
.dashboard {
padding: 16px;
}
.title {
font-size: 28px;
}
.stats-grid {
grid-template-columns: 1fr;
}
.charts-grid {
grid-template-columns: 1fr;
}
.liquidation-details {
grid-template-columns: 1fr 1fr;
}
}Adds all the visual components with colored badges (LONG/SHORT, liquidation types), detail cards, and utility classes for text.
Open your terminal and run npm start; this will start our dapp on this local address: http://localhost:3000.

Now that your dapp is working, we need to see our data. To do this, start your API server from Part 3. You should see output similar to this:


Once the API server is running, your dashboard will automatically connect and display the live liquidation data. And that's it; you can now see liquidations occur in real time.
You've now built a complete real-time liquidation dashboard that brings all your data to life. What started as JSON files is now a live interface that displays liquidations as they occur, positions at risk, and market trends. This concludes our four-part series, which spans collecting blockchain data, building an API, and creating this crypto liquidation dashboard.
From here, you can take this wherever you want. Add alerts for big liquidations, and throw in some filters to focus on specific coins or wallets. The foundation is solid, so whatever you build on top should fit in easily. Happy Coding!