A live odds dashboard is one of those projects that looks simple — fetch some JSON, display it, refresh on a timer — and then quietly becomes a re-render nightmare. Here's a working React 18+ implementation that doesn't grind your browser when 200 odds update in the same second.
What we're building
A two-column dashboard: left = list of live matches, right = the selected match's 1X2 + Over/Under 2.5 prices, updating every second.
1. The fetch hook (polling-based, cache-aware)
import { useEffect, useState, useRef } from 'react';
export function useLiveOdds(sportId = 1, intervalMs = 1500) {
const [events, setEvents] = useState([]);
const etagRef = useRef('');
useEffect(() => {
let alive = true;
async function tick() {
try {
const r = await fetch(`https://api.euro365.bet/v1/events?sport=${sportId}&live=1`, {
headers: { 'X-API-Key': import.meta.env.VITE_E365_KEY, 'If-None-Match': etagRef.current }
});
if (r.status === 304) return; // nothing changed, no re-render
etagRef.current = r.headers.get('etag') || '';
const data = await r.json();
if (alive) setEvents(data.events ?? []);
} catch (_) { /* swallow; next tick will retry */ }
}
tick();
const id = setInterval(tick, intervalMs);
return () => { alive = false; clearInterval(id); };
}, [sportId, intervalMs]);
return events;
}
Key detail: we send If-None-Match. The API answers 304 most of the time. No JSON parsing, no setState, no re-render. This single change cuts CPU by ~80% vs naive polling.
2. The match list
function MatchList({ onPick, picked }) {
const events = useLiveOdds(1, 2000); // 2s for the list — frequent enough
return (
<ul className="match-list">
{events.map(ev => (
<li key={ev._id}
className={picked === ev._id ? 'on' : ''}
onClick={() => onPick(ev._id)}>
<span>{ev.h} vs {ev.a}</span>
<small>{ev.score ?? '0:0'}</small>
</li>
))}
</ul>
);
}
3. The detail panel (faster polling, narrower payload)
function MatchDetail({ eventId }) {
const [odds, setOdds] = useState({});
useEffect(() => {
if (!eventId) return;
let alive = true;
async function tick() {
const r = await fetch(`https://api.euro365.bet/v1/odds?events=${eventId}&markets=1001,1018`, {
headers: { 'X-API-Key': import.meta.env.VITE_E365_KEY }
});
const d = await r.json();
if (alive) setOdds(d[eventId] ?? {});
}
tick();
const id = setInterval(tick, 1000); // 1s for the focused match
return () => { alive = false; clearInterval(id); };
}, [eventId]);
if (!eventId) return <div className="empty">Pick a match</div>;
return <OddsTable odds={odds} />;
}
4. Stable rendering: avoid the 200-tick re-render storm
If you blindly setState on every fetch, every odds change re-renders the whole tree. Three quick wins:
-
memo the row components so unchanged matches don't repaint:
const Row = React.memo(({ ev }) => ...) - shallow-compare before setState — if the new payload is structurally identical, skip the setState entirely.
-
use a stable key — the
_idfrom the events endpoint is monotonically stable across the event's lifetime; don't synthesize keys from array index.
5. Upgrade to WebSocket later (not first)
Ship polling, measure, then consider WebSocket. For a dashboard with < 30 visible matches, polling at 1–2s is fine and easier to debug.
Production tip: never hardcode the API key in client-side React. Proxy through your own backend (Next.js API route, Lambda, whatever) so the key stays server-side. Most sports-data APIs let you create a domain-locked key for browser use if you really must — but server-proxy is still safer.
I built this against the Euro365 sports betting odds API — they have a free tier (100 req/min, no card required) which is plenty for prototyping a dashboard. The same patterns work against any odds API that supports ETag/Last-Modified on the events endpoint.
The original post lives at https://api.euro365.bet/blog/react-live-odds-dashboard/ — drop a comment if you've shipped something similar and hit a different gotcha.
Top comments (0)