DEV Community

Cover image for How I Built a Real-Time Crypto Dashboard with CoinStats API and React
Kevin Meneses González
Kevin Meneses González

Posted on • Originally published at Medium

How I Built a Real-Time Crypto Dashboard with CoinStats API and React

Most crypto APIs are either free and broken, or reliable and expensive.

If you've tried to build something real with the usual suspects:

  • CoinGecko rate-limits you at 10–30 req/min before you even have a key,
  • CoinMarketCap puts historical data behind a $29+/month paywall,
  • Binance API only knows assets that trade on Binance.

You hit the wall before the first component renders.

I built a real-time crypto dashboard using CoinStats API — deployed, fully functional, four data sections running in parallel. This article covers the actual implementation: the endpoints I used, the decisions I made, and the bugs I hit that the documentation doesn't mention.

The full source is on GitHub: github.com/Kevinelectronics/cryptodashboard


Why the Usual Options Don't Work

Before the solution, the problem. Because the tradeoffs matter when you're architecting a dashboard with multiple data types running simultaneously.

API Free Rate Limit Historical Charts News Feed Global Stats
CoinGecko ~10–30 req/min, no key ✅ limited /global
CoinMarketCap Latest quote only ❌ paid ❌ free tier
Binance Exchange assets only ✅ OHLCV
CoinStats 100 req/min with key /coins/{id}/charts /news /markets

The news endpoint alone eliminates a second API integration. For a dashboard that needs coins + charts + market context + news from a single base URL, CoinStats is the only free-tier option that doesn't break the architecture.

👉 Get your free CoinStats API key here — no credit card required.


What I Built

A single-page React app with four vertical sections:

  1. Global Market Stats — 4 cards: Total Market Cap, 24h Volume, BTC Dominance, Alt Dominance
  2. Coin Table — Top 50 coins, sortable by any column, with real-time search by name or symbol
  3. Price Chart — Appears inline when you click a coin. Color and gradient change based on price direction. Period selector: 1D, 1W, 1M, 3M, 6M, 1Y
  4. News Feed — 6 recent crypto news items with image, source, and timestamp

Stack: Vite 5 + React 19, JavaScript (no TypeScript), Tailwind CSS v4, Recharts. Dark mode only — GitHub-style #0d1117 background.

[Screenshot: full dashboard view — global stats cards at top, coin table below, news grid at bottom]


What the API Actually Returns

Real JSON shapes before any component code. Technical readers need to see the structure before trusting an API.

GET /coins?limit=50

{
  "result": [
    {
      "id": "bitcoin",
      "symbol": "BTC",
      "name": "Bitcoin",
      "icon": "https://static.coinstats.app/coins/1650455588819.png",
      "rank": 1,
      "price": 67420.34,
      "priceChange1h": 0.21,
      "priceChange1d": 2.14,
      "priceChange1w": -3.45,
      "volume": 28940000000,
      "marketCap": 1324000000000,
      "availableSupply": 19700000,
      "totalSupply": 21000000
    }
  ],
  "meta": { "itemCount": 50, "totalCount": 14000 }
}
Enter fullscreen mode Exit fullscreen mode

Use priceChange1d and priceChange1w directly — they're already percentages. Don't compute (price - price24hAgo) / price24hAgo * 100 manually. The API does that for you.

GET /markets

{
  "totalMarketCap": 2340000000000,
  "totalVolume": 98700000000,
  "btcDominance": 52.4,
  "altDominance": 47.6,
  "marketCapChange": 1.8
}
Enter fullscreen mode Exit fullscreen mode

⚠️ Gotcha — the endpoint is /markets, not /global. The docs reference a /global endpoint in older versions. It returns a 404. If you spend 20 minutes wondering why your global stats call keeps failing, this is why. The correct endpoint is /markets.

GET /coins/{id}/charts?period=1w

{
  "result": [
    [1714000000000, 62100.5, 0.00092, 31200000000],
    [1714086400000, 63450.2, 0.00094, 29800000000]
  ]
}
Enter fullscreen mode Exit fullscreen mode

Each item is [timestamp, price, priceBtc, volume]four values, not two. Most tutorials show data.map(([time, price]) => ...) and silently discard priceBtc and volume. That's fine if you only need price, but you should destructure explicitly so it's clear:

result.map(([time, price, priceBtc, volume]) => ({
  time: new Date(time).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
  price,
  volume
}))
Enter fullscreen mode Exit fullscreen mode

GET /news?limit=6

{
  "news": [
    {
      "id": "abc123",
      "title": "Bitcoin hits new monthly high amid ETF inflows",
      "description": "Spot ETFs saw $400M in net inflows...",
      "feedName": "CoinDesk",
      "imgUrl": "https://...",
      "link": "https://...",
      "shareURL": "https://...",
      "reactionsCount": 42,
      "publishedAt": "2024-04-26T08:00:00Z"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

imgUrl is ready to drop into an <img> tag. publishedAt is ISO 8601 — pass it to new Date() and format from there.


Project Setup

npm create vite@latest crypto-dashboard -- --template react
cd crypto-dashboard
npm install recharts
npm install -D tailwindcss @tailwindcss/vite
Enter fullscreen mode Exit fullscreen mode

Tailwind v4 configuration in vite.config.js:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'

export default defineConfig({
  plugins: [react(), tailwindcss()]
})
Enter fullscreen mode Exit fullscreen mode

In src/index.css:

@import "tailwindcss";
Enter fullscreen mode Exit fullscreen mode

Create .env in the root:

VITE_COINSTATS_KEY=your_api_key_here
Enter fullscreen mode Exit fullscreen mode

Access it everywhere as import.meta.env.VITE_COINSTATS_KEY.


The Data Hooks

Four endpoints, four hooks. Each follows the same pattern: loading state, error state, cleanup flag on unmount.

useCoinList — The main table data

// hooks/useCoinList.js
import { useState, useEffect } from 'react';

const BASE = 'https://openapiv1.coinstats.app';
const HEADERS = { 'X-API-KEY': import.meta.env.VITE_COINSTATS_KEY };

export function useCoinList(limit = 50) {
  const [coins, setCoins] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;

    async function fetchCoins() {
      try {
        const res = await fetch(`${BASE}/coins?limit=${limit}`, { headers: HEADERS });
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        const { result } = await res.json();
        if (!cancelled) setCoins(result);
      } catch (err) {
        if (!cancelled) setError(err.message);
      } finally {
        if (!cancelled) setLoading(false);
      }
    }

    fetchCoins();
    return () => { cancelled = true; };
  }, [limit]);

  return { coins, loading, error };
}
Enter fullscreen mode Exit fullscreen mode

The cancelled = true pattern is not optional. If the user navigates away or a parent re-renders before the fetch resolves, React throws a warning about setting state on an unmounted component. The cleanup function silences it by checking the flag before every setState call.

useMarketStats — Global market cards

// hooks/useMarketStats.js
import { useState, useEffect } from 'react';

export function useMarketStats() {
  const [stats, setStats] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    let cancelled = false;
    fetch('https://openapiv1.coinstats.app/markets', {
      headers: { 'X-API-KEY': import.meta.env.VITE_COINSTATS_KEY }
    })
      .then(res => res.json())
      .then(data => { if (!cancelled) { setStats(data); setLoading(false); } });
    return () => { cancelled = true; };
  }, []);

  return { stats, loading };
}
Enter fullscreen mode Exit fullscreen mode

useChartData — Price history per coin

// hooks/useChartData.js
import { useState, useEffect, useCallback } from 'react';

const BASE = 'https://openapiv1.coinstats.app';
const HEADERS = { 'X-API-KEY': import.meta.env.VITE_COINSTATS_KEY };

export function useChartData(coinId, period = '1w') {
  const [chartData, setChartData] = useState([]);
  const [loading, setLoading] = useState(false);

  const fetchChart = useCallback(async () => {
    if (!coinId) return;
    setLoading(true);
    let cancelled = false;

    try {
      const res = await fetch(
        `${BASE}/coins/${coinId}/charts?period=${period}`,
        { headers: HEADERS }
      );
      const { result } = await res.json();
      const formatted = result.map(([time, price]) => ({
        time: new Date(time).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
        price
      }));
      if (!cancelled) setChartData(formatted);
    } finally {
      if (!cancelled) setLoading(false);
    }

    return () => { cancelled = true; };
  }, [coinId, period]);

  useEffect(() => { fetchChart(); }, [fetchChart]);

  return { chartData, loading };
}
Enter fullscreen mode Exit fullscreen mode

useCallback wraps the fetch function so it only recreates when coinId or period changes. Without it, every parent re-render generates a new function reference → triggers useEffect → fires another fetch → infinite loop. With the wrong deps array, your chart refetches on every keystroke in the search box.

useNews — News feed

// hooks/useNews.js
import { useState, useEffect } from 'react';

export function useNews(limit = 6) {
  const [news, setNews] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    let cancelled = false;
    fetch(`https://openapiv1.coinstats.app/news?limit=${limit}`, {
      headers: { 'X-API-KEY': import.meta.env.VITE_COINSTATS_KEY }
    })
      .then(res => res.json())
      .then(data => { if (!cancelled) { setNews(data.news); setLoading(false); } });
    return () => { cancelled = true; };
  }, [limit]);

  return { news, loading };
}
Enter fullscreen mode Exit fullscreen mode

Section 1: Global Market Stats

[Screenshot: 4 stat cards — Total Market Cap, 24h Volume, BTC Dominance, Alt Dominance — on dark background]

// components/MarketStats.jsx
import { useMarketStats } from '../hooks/useMarketStats';

function StatCard({ label, value, change }) {
  return (
    <div className="bg-[#161b22] rounded-xl p-5 border border-[#30363d]">
      <p className="text-[#8b949e] text-sm mb-1">{label}</p>
      <p className="text-white text-2xl font-semibold">{value}</p>
      {change !== undefined && (
        <p className={`text-sm mt-1 ${change >= 0 ? 'text-green-400' : 'text-red-400'}`}>
          {change >= 0 ? '+' : ''}{change?.toFixed(2)}%
        </p>
      )}
    </div>
  );
}

export function MarketStats() {
  const { stats, loading } = useMarketStats();

  if (loading) return (
    <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
      {[...Array(4)].map((_, i) => (
        <div key={i} className="bg-[#161b22] rounded-xl p-5 h-24 animate-pulse border border-[#30363d]" />
      ))}
    </div>
  );

  return (
    <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
      <StatCard
        label="Total Market Cap"
        value={`$${(stats.totalMarketCap / 1e12).toFixed(2)}T`}
        change={stats.marketCapChange}
      />
      <StatCard
        label="24h Volume"
        value={`$${(stats.totalVolume / 1e9).toFixed(0)}B`}
      />
      <StatCard label="BTC Dominance" value={`${stats.btcDominance?.toFixed(1)}%`} />
      <StatCard label="Alt Dominance" value={`${stats.altDominance?.toFixed(1)}%`} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The skeleton uses animate-pulse from Tailwind — same dimensions as the real card, no layout shift when data arrives. No spinners anywhere in the app. Spinners are disorienting because the user can't tell how much is loading. Skeletons show exactly what's coming.


Section 2: Coin Table with Sort and Search

[Screenshot: coin table with sortable headers, BTC row highlighted, search bar active]

// components/CoinTable.jsx
import { useState, useMemo } from 'react';
import { useCoinList } from '../hooks/useCoinList';

const COLUMNS = [
  { key: 'rank', label: '#' },
  { key: 'name', label: 'Name' },
  { key: 'price', label: 'Price' },
  { key: 'priceChange1h', label: '1h %' },
  { key: 'priceChange1d', label: '24h %' },
  { key: 'priceChange1w', label: '7d %' },
  { key: 'marketCap', label: 'Market Cap' },
];

export function CoinTable({ onSelectCoin }) {
  const { coins, loading } = useCoinList(50);
  const [query, setQuery] = useState('');
  const [sortKey, setSortKey] = useState('rank');
  const [sortDir, setSortDir] = useState(1); // 1 = asc, -1 = desc

  function handleSort(key) {
    if (key === sortKey) {
      setSortDir(sortDir * -1); // toggle direction
    } else {
      setSortKey(key);
      setSortDir(1);
    }
  }

  const filtered = useMemo(() => {
    const q = query.toLowerCase();
    return coins.filter(c =>
      c.name.toLowerCase().includes(q) || c.symbol.toLowerCase().includes(q)
    );
  }, [coins, query]);

  const sorted = useMemo(() => {
    return [...filtered].sort((a, b) => {
      const aVal = a[sortKey] ?? 0;
      const bVal = b[sortKey] ?? 0;
      if (typeof aVal === 'string') return sortDir * aVal.localeCompare(bVal);
      return sortDir * (aVal - bVal);
    });
  }, [filtered, sortKey, sortDir]);

  if (loading) return (
    <div className="space-y-2">
      {[...Array(10)].map((_, i) => (
        <div key={i} className="h-12 bg-[#161b22] rounded animate-pulse border border-[#30363d]" />
      ))}
    </div>
  );

  return (
    <div>
      <input
        type="text"
        placeholder="Search coin or symbol..."
        value={query}
        onChange={e => setQuery(e.target.value)}
        className="mb-4 w-full bg-[#161b22] border border-[#30363d] rounded-lg px-4 py-2 text-white placeholder-[#8b949e] focus:outline-none focus:border-blue-500"
      />
      <div className="overflow-x-auto">
        <table className="w-full text-sm">
          <thead>
            <tr className="text-[#8b949e] border-b border-[#30363d]">
              {COLUMNS.map(col => (
                <th
                  key={col.key}
                  onClick={() => handleSort(col.key)}
                  className="py-3 px-4 text-left cursor-pointer select-none hover:text-white transition-colors"
                >
                  {col.label}
                  {sortKey === col.key && (sortDir === 1 ? '' : '')}
                </th>
              ))}
            </tr>
          </thead>
          <tbody>
            {sorted.map(coin => (
              <tr
                key={coin.id}
                onClick={() => onSelectCoin(coin)}
                className="border-b border-[#21262d] hover:bg-[#161b22] cursor-pointer transition-colors"
              >
                <td className="py-3 px-4 text-[#8b949e]">{coin.rank}</td>
                <td className="py-3 px-4">
                  <div className="flex items-center gap-2">
                    <img src={coin.icon} alt={coin.name} className="w-6 h-6 rounded-full" />
                    <span className="text-white font-medium">{coin.name}</span>
                    <span className="text-[#8b949e]">{coin.symbol}</span>
                  </div>
                </td>
                <td className="py-3 px-4 text-white font-mono">
                  ${coin.price >= 1 ? coin.price.toLocaleString() : coin.price.toFixed(6)}
                </td>
                {['priceChange1h', 'priceChange1d', 'priceChange1w'].map(k => (
                  <td key={k} className={`py-3 px-4 font-mono ${coin[k] >= 0 ? 'text-green-400' : 'text-red-400'}`}>
                    {coin[k] >= 0 ? '+' : ''}{coin[k]?.toFixed(2)}%
                  </td>
                ))}
                <td className="py-3 px-4 text-[#8b949e]">
                  ${(coin.marketCap / 1e9).toFixed(1)}B
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

sortDir * -1 toggles between ascending and descending with one multiplication. No switch statement, no ternary chain. useMemo on both filtered and sorted means the search and sort operations don't re-run on unrelated renders — only when coins, query, sortKey, or sortDir actually change.


Section 3: The Inline Price Chart

[Screenshot: price chart expanded below a coin row — green gradient, period selector 1W active]

The chart appears inline when the user clicks a coin row. It doesn't navigate anywhere. onSelectCoin in the parent sets a state variable; the chart renders conditionally below the table.

// components/PriceChart.jsx
import { useState, useMemo } from 'react';
import {
  AreaChart, Area, XAxis, YAxis, Tooltip,
  ResponsiveContainer, defs, linearGradient, stop
} from 'recharts';
import { useChartData } from '../hooks/useChartData';

const PERIODS = ['1d', '1w', '1m', '3m', '6m', '1y'];

export function PriceChart({ coin }) {
  const [period, setPeriod] = useState('1w');
  const { chartData, loading } = useChartData(coin.id, period);

  // Determine price direction for the selected period
  const isUp = useMemo(() => {
    if (chartData.length < 2) return true;
    return chartData[chartData.length - 1].price >= chartData[0].price;
  }, [chartData]);

  const color = isUp ? '#22c55e' : '#ef4444'; // green-500 / red-500

  return (
    <div className="bg-[#161b22] rounded-xl p-5 border border-[#30363d] mt-2">
      <div className="flex items-center justify-between mb-4">
        <div>
          <span className="text-white font-semibold text-lg">{coin.name}</span>
          <span className="text-[#8b949e] ml-2">{coin.symbol}</span>
        </div>
        <div className="flex gap-1">
          {PERIODS.map(p => (
            <button
              key={p}
              onClick={() => setPeriod(p)}
              className={`px-3 py-1 rounded text-sm uppercase transition-colors ${
                period === p
                  ? 'bg-blue-600 text-white'
                  : 'text-[#8b949e] hover:text-white'
              }`}
            >
              {p}
            </button>
          ))}
        </div>
      </div>

      {loading ? (
        <div className="h-64 animate-pulse bg-[#0d1117] rounded" />
      ) : (
        <ResponsiveContainer width="100%" height={260}>
          <AreaChart data={chartData}>
            <defs>
              <linearGradient id="chartGradient" x1="0" y1="0" x2="0" y2="1">
                <stop offset="5%" stopColor={color} stopOpacity={0.3} />
                <stop offset="95%" stopColor={color} stopOpacity={0} />
              </linearGradient>
            </defs>
            <XAxis
              dataKey="time"
              tick={{ fill: '#8b949e', fontSize: 11 }}
              axisLine={false}
              tickLine={false}
              interval="preserveStartEnd"
            />
            <YAxis
              domain={['auto', 'auto']}
              tick={{ fill: '#8b949e', fontSize: 11 }}
              axisLine={false}
              tickLine={false}
              tickFormatter={v => `$${v >= 1000 ? (v / 1000).toFixed(1) + 'k' : v.toFixed(2)}`}
            />
            <Tooltip
              contentStyle={{ background: '#161b22', border: '1px solid #30363d', borderRadius: 8 }}
              labelStyle={{ color: '#8b949e' }}
              formatter={v => [`$${v.toLocaleString()}`, 'Price']}
            />
            <Area
              type="monotone"
              dataKey="price"
              stroke={color}
              strokeWidth={2}
              fill="url(#chartGradient)"
              dot={false}
            />
          </AreaChart>
        </ResponsiveContainer>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The gradient ID chartGradient is static — if you render multiple charts at once, give each a unique ID. In this dashboard there's only one chart visible at a time, so it's not an issue.

isUp compares chartData[0].price against chartData[chartData.length - 1].price. When the user switches from 1W to 1M, the hook refetches, chartData updates, isUp recalculates, and the color flips automatically. No extra state needed.


Section 4: News Feed

[Screenshot: 2x3 news grid — dark cards, article images, source badge and timestamp]

// components/NewsFeed.jsx
import { useNews } from '../hooks/useNews';

function NewsCard({ item }) {
  const date = new Date(item.publishedAt).toLocaleDateString('en-US', {
    month: 'short', day: 'numeric', year: 'numeric'
  });

  return (
    <a
      href={item.link}
      target="_blank"
      rel="noopener noreferrer"
      className="bg-[#161b22] rounded-xl overflow-hidden border border-[#30363d] hover:border-blue-500 transition-colors flex flex-col"
    >
      {item.imgUrl && (
        <img
          src={item.imgUrl}
          alt={item.title}
          className="w-full h-40 object-cover"
          onError={e => { e.target.style.display = 'none'; }}
        />
      )}
      <div className="p-4 flex flex-col flex-1">
        <p className="text-white font-medium text-sm leading-snug line-clamp-2 mb-2">
          {item.title}
        </p>
        <div className="mt-auto flex items-center justify-between text-xs text-[#8b949e]">
          <span>{item.feedName}</span>
          <span>{date}</span>
        </div>
      </div>
    </a>
  );
}

export function NewsFeed() {
  const { news, loading } = useNews(6);

  if (loading) return (
    <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
      {[...Array(6)].map((_, i) => (
        <div key={i} className="h-64 bg-[#161b22] rounded-xl animate-pulse border border-[#30363d]" />
      ))}
    </div>
  );

  return (
    <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
      {news.map(item => <NewsCard key={item.id} item={item} />)}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

onError on the image handles broken CDN URLs gracefully — hides the image element instead of showing a broken icon. Small detail, but news images break often enough that it matters in production.


Composing the App

// App.jsx
import { useState } from 'react';
import { MarketStats } from './components/MarketStats';
import { CoinTable } from './components/CoinTable';
import { PriceChart } from './components/PriceChart';
import { NewsFeed } from './components/NewsFeed';

export default function App() {
  const [selectedCoin, setSelectedCoin] = useState(null);

  return (
    <div className="min-h-screen bg-[#0d1117] text-white">
      <div className="max-w-7xl mx-auto px-4 py-8 space-y-10">

        <header>
          <h1 className="text-2xl font-bold">Crypto Dashboard</h1>
          <p className="text-[#8b949e] text-sm mt-1">Powered by CoinStats API</p>
        </header>

        <section>
          <h2 className="text-lg font-semibold mb-4">Market Overview</h2>
          <MarketStats />
        </section>

        <section>
          <h2 className="text-lg font-semibold mb-4">Top 50 Coins</h2>
          <CoinTable onSelectCoin={setSelectedCoin} />
          {selectedCoin && <PriceChart coin={selectedCoin} />}
        </section>

        <section>
          <h2 className="text-lg font-semibold mb-4">Latest News</h2>
          <NewsFeed />
        </section>

      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

No router. No context. No global state manager. One useState at the top level passes the selected coin down to PriceChart. For a single-page dashboard, this is exactly as complex as it needs to be.


How Many API Calls Does the Dashboard Make?

At cold load, before any user interaction:

Call Endpoint Fires
Market stats GET /markets On mount
Coin list GET /coins?limit=50 On mount
News GET /news?limit=6 On mount
Price chart GET /coins/{id}/charts?period=1w On coin click

3 calls on load. 1 additional on first coin click. 1 per period change.

CoinStats free tier allows 100 req/min. The dashboard at idle uses 3 calls total. Even with aggressive period switching (6 periods × 5 coins), you'd use ~33 calls — well within limits.


API Key Security

In a Vite app, VITE_ prefixed variables are bundled into the JavaScript. Anyone who opens DevTools → Sources can read your key.

For this dashboard as a demo project, that's an acceptable tradeoff.

For a public production deployment:

Option 1 — Cloudflare Worker proxy: Your frontend calls https://your-worker.workers.dev/coins, the worker injects X-API-KEY server-side and forwards to CoinStats. The key never appears in the browser bundle. Free tier is 100,000 requests/day.

Option 2 — Express backend: Same logic, more control, more infrastructure. Only worth it if you're already running a server.

The architecture change is minimal — replace the base URL constant in your hooks with your proxy URL. Everything else stays identical.


FAQ

Do I need a backend to use CoinStats API?

No. All four endpoints used in this dashboard work directly from the browser. A backend is only necessary if you want to keep your API key out of the JS bundle.

How do I protect the API key in production?

Use a Cloudflare Worker as a transparent proxy. It injects the key server-side before forwarding requests to CoinStats. Your frontend never sees the key. Setup takes about 15 minutes.

Why Recharts instead of Chart.js or D3?

Recharts is declarative and built for React. Chart.js requires an imperative configuration model that fights React's rendering cycle. D3 is powerful for custom visualizations but overkill for a standard price area chart. Recharts ships ResponsiveContainer which handles resize automatically — no ResizeObserver wiring needed.

Why no TypeScript?

Deliberate choice for this project. The API responses are well-structured and the codebase is small enough that the overhead of maintaining types doesn't pay off. In a larger app or a team project, TypeScript makes sense.

Does this work with React 19?

Yes. All hooks used — useState, useEffect, useMemo, useCallback — are stable across React 16+. The cancelled flag cleanup pattern works identically in React 19.

Can I add portfolio tracking without a backend?

Yes, for local tracking. Store holdings as { coinId, amount } in localStorage, fetch current prices from /coins, and compute portfolio value client-side with useMemo. For cross-device sync, you need a backend.


Wrapping Up

Four data sections. Four hooks. One API.

The architecture decisions here aren't clever — they're the minimum viable structure for a dashboard that doesn't fight React: custom hooks that own their fetch lifecycle, cleanup flags that prevent stale state updates, useMemo for derived data, and a single state variable to coordinate the chart.

CoinStats API handles the rest: coins, charts, market context, and news from one base URL, on a free tier that doesn't require a credit card.

The full source is available on GitHub — clone it, break it, and build something better: github.com/Kevinelectronics/cryptodashboard


Looking for technical content for your company? I can help — LinkedIn · kevinmenesesgonzalez@gmail.com

Top comments (0)