
{"id":157224,"date":"2026-04-27T16:56:24","date_gmt":"2026-04-27T16:56:24","guid":{"rendered":"https:\/\/mycryptomania.com\/?p=157224"},"modified":"2026-04-27T16:56:24","modified_gmt":"2026-04-27T16:56:24","slug":"how-i-built-a-real-time-crypto-dashboard-with-coinstats-api-and-react","status":"publish","type":"post","link":"https:\/\/mycryptomania.com\/?p=157224","title":{"rendered":"How I Built a Real-Time Crypto Dashboard with CoinStats API and React"},"content":{"rendered":"<p>Most crypto APIs are either free and broken, or reliable and expensive.<\/p>\n<p>If you\u2019ve tried to build something real with the usual suspects:<\/p>\n<p>CoinGecko rate-limits you at 10\u201330 req\/min before you even have a\u00a0key,CoinMarketCap puts historical data behind a $29+\/month paywall,Binance API only knows assets that trade on\u00a0Binance.<\/p>\n<p>You hit the wall before the first component renders.<\/p>\n<p>I built a real-time crypto dashboard using <a href=\"https:\/\/coinstats.app\/api-docs\/\">CoinStats API\u200a<\/a>\u2014\u200adeployed, 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\u2019t\u00a0mention.<\/p>\n<h3>Why the Usual Options Don\u2019t\u00a0Work<\/h3>\n<p>Before the solution, the problem. Because the tradeoffs matter when you\u2019re architecting a dashboard with multiple data types running simultaneously.<\/p>\n<p>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\u2019t break the architecture.<\/p>\n<h3>What I\u00a0Built<\/h3>\n<p>A single-page React app with four vertical sections:<\/p>\n<p><strong>Global Market Stats<\/strong>\u200a\u2014\u200a4 cards: Total Market Cap, 24h Volume, BTC Dominance, Alt Dominance<strong>Coin Table<\/strong>\u200a\u2014\u200aTop 50 coins, sortable by any column, with real-time search by name or\u00a0symbol<strong>Price Chart<\/strong>\u200a\u2014\u200aAppears inline when you click a coin. Color and gradient change based on price direction. Period selector: 1D, 1W, 1M, 3M, 6M,\u00a01Y<strong>News Feed<\/strong>\u200a\u2014\u200a6 recent crypto news items with image, source, and timestamp<\/p>\n<p>Stack: Vite 5 + React 19, JavaScript (no TypeScript), Tailwind CSS v4, Recharts. Dark mode only\u200a\u2014\u200aGitHub-style #0d1117 background.<\/p>\n<h3>What the API Actually\u00a0Returns<\/h3>\n<p>Real JSON shapes before any component code. Technical readers need to see the structure before trusting an\u00a0API.<\/p>\n<h3>GET \/coins?limit=50<\/h3>\n<p>{<br \/>  &#8220;result&#8221;: [<br \/>    {<br \/>      &#8220;id&#8221;: &#8220;bitcoin&#8221;,<br \/>      &#8220;symbol&#8221;: &#8220;BTC&#8221;,<br \/>      &#8220;name&#8221;: &#8220;Bitcoin&#8221;,<br \/>      &#8220;icon&#8221;: &#8220;https:\/\/static.coinstats.app\/coins\/1650455588819.png&#8221;,<br \/>      &#8220;rank&#8221;: 1,<br \/>      &#8220;price&#8221;: 67420.34,<br \/>      &#8220;priceChange1h&#8221;: 0.21,<br \/>      &#8220;priceChange1d&#8221;: 2.14,<br \/>      &#8220;priceChange1w&#8221;: -3.45,<br \/>      &#8220;volume&#8221;: 28940000000,<br \/>      &#8220;marketCap&#8221;: 1324000000000,<br \/>      &#8220;availableSupply&#8221;: 19700000,<br \/>      &#8220;totalSupply&#8221;: 21000000<br \/>    }<br \/>  ],<br \/>  &#8220;meta&#8221;: { &#8220;itemCount&#8221;: 50, &#8220;totalCount&#8221;: 14000 }<br \/>}<\/p>\n<p>Use priceChange1d and priceChange1w directly\u200a\u2014\u200athey&#8217;re already percentages. Don&#8217;t compute (price &#8211; price24hAgo) \/ price24hAgo * 100 manually. The API does that for\u00a0you.<\/p>\n<h3>GET \/markets<\/h3>\n<p>{<br \/>  &#8220;totalMarketCap&#8221;: 2340000000000,<br \/>  &#8220;totalVolume&#8221;: 98700000000,<br \/>  &#8220;btcDominance&#8221;: 52.4,<br \/>  &#8220;altDominance&#8221;: 47.6,<br \/>  &#8220;marketCapChange&#8221;: 1.8<br \/>}\u26a0\ufe0f <strong>Gotcha\u200a\u2014\u200athe endpoint is <\/strong><strong>\/markets, not <\/strong><strong>\/global.<\/strong> 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.<\/p>\n<h3>GET \/coins\/{id}\/charts?period=1w<\/h3>\n<p>{<br \/>  &#8220;result&#8221;: [<br \/>    [1714000000000, 62100.5, 0.00092, 31200000000],<br \/>    [1714086400000, 63450.2, 0.00094, 29800000000]<br \/>  ]<br \/>}<\/p>\n<p>Each item is [timestamp, price, priceBtc, volume]\u200a\u2014\u200a<strong>four values, not two.<\/strong> Most tutorials show data.map(([time, price]) =&gt;\u00a0&#8230;) and silently discard priceBtc and volume. That&#8217;s fine if you only need price, but you should destructure explicitly so it&#8217;s\u00a0clear:<\/p>\n<p>result.map(([time, price, priceBtc, volume]) =&gt; ({<br \/>  time: new Date(time).toLocaleDateString(&#8216;en-US&#8217;, { month: &#8216;short&#8217;, day: &#8216;numeric&#8217; }),<br \/>  price,<br \/>  volume<br \/>}))<\/p>\n<h3>GET \/news?limit=6<\/h3>\n<p>{<br \/>  &#8220;news&#8221;: [<br \/>    {<br \/>      &#8220;id&#8221;: &#8220;abc123&#8221;,<br \/>      &#8220;title&#8221;: &#8220;Bitcoin hits new monthly high amid ETF inflows&#8221;,<br \/>      &#8220;description&#8221;: &#8220;Spot ETFs saw $400M in net inflows&#8230;&#8221;,<br \/>      &#8220;feedName&#8221;: &#8220;CoinDesk&#8221;,<br \/>      &#8220;imgUrl&#8221;: &#8220;https:\/\/&#8230;&#8221;,<br \/>      &#8220;link&#8221;: &#8220;https:\/\/&#8230;&#8221;,<br \/>      &#8220;shareURL&#8221;: &#8220;https:\/\/&#8230;&#8221;,<br \/>      &#8220;reactionsCount&#8221;: 42,<br \/>      &#8220;publishedAt&#8221;: &#8220;2024-04-26T08:00:00Z&#8221;<br \/>    }<br \/>  ]<br \/>}<\/p>\n<p>imgUrl is ready to drop into an &lt;img&gt; tag. publishedAt is ISO 8601\u200a\u2014\u200apass it to new Date() and format from\u00a0there.<\/p>\n<h3>Project Setup<\/h3>\n<p>npm create vite@latest crypto-dashboard &#8212; &#8211;template react<br \/>cd crypto-dashboard<br \/>npm install recharts<br \/>npm install -D tailwindcss @tailwindcss\/vite<\/p>\n<p>Tailwind v4 configuration in vite.config.js:<\/p>\n<p>import { defineConfig } from &#8216;vite&#8217;<br \/>import react from &#8216;@vitejs\/plugin-react&#8217;<br \/>import tailwindcss from &#8216;@tailwindcss\/vite&#8217;export default defineConfig({<br \/>  plugins: [react(), tailwindcss()]<br \/>})<\/p>\n<p>In src\/index.css:<\/p>\n<p>@import &#8220;tailwindcss&#8221;;<\/p>\n<p>Create\u00a0.env in the\u00a0root:<\/p>\n<p>VITE_COINSTATS_KEY=your_api_key_here<\/p>\n<p>Access it everywhere as import.meta.env.VITE_COINSTATS_KEY.<\/p>\n<h3>The Data\u00a0Hooks<\/h3>\n<p>Four endpoints, four hooks. Each follows the same pattern: loading state, error state, cleanup flag on\u00a0unmount.<\/p>\n<h3>useCoinList\u200a\u2014\u200aThe main table\u00a0data<\/h3>\n<p>\/\/ hooks\/useCoinList.js<br \/>import { useState, useEffect } from &#8216;react&#8217;;<br \/>const BASE = &#8216;https:\/\/openapiv1.coinstats.app&#8217;;<br \/>const HEADERS = { &#8216;X-API-KEY&#8217;: import.meta.env.VITE_COINSTATS_KEY };<br \/>export function useCoinList(limit = 50) {<br \/>  const [coins, setCoins] = useState([]);<br \/>  const [loading, setLoading] = useState(true);<br \/>  const [error, setError] = useState(null);<br \/>  useEffect(() =&gt; {<br \/>    let cancelled = false;<br \/>    async function fetchCoins() {<br \/>      try {<br \/>        const res = await fetch(`${BASE}\/coins?limit=${limit}`, { headers: HEADERS });<br \/>        if (!res.ok) throw new Error(`HTTP ${res.status}`);<br \/>        const { result } = await res.json();<br \/>        if (!cancelled) setCoins(result);<br \/>      } catch (err) {<br \/>        if (!cancelled) setError(err.message);<br \/>      } finally {<br \/>        if (!cancelled) setLoading(false);<br \/>      }<br \/>    }<br \/>    fetchCoins();<br \/>    return () =&gt; { cancelled = true; };<br \/>  }, [limit]);<br \/>  return { coins, loading, error };<br \/>}<\/p>\n<p>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\u00a0call.<\/p>\n<h3>useMarketStats\u200a\u2014\u200aGlobal market\u00a0cards<\/h3>\n<p>\/\/ hooks\/useMarketStats.js<br \/>import { useState, useEffect } from &#8216;react&#8217;;<br \/>export function useMarketStats() {<br \/>  const [stats, setStats] = useState(null);<br \/>  const [loading, setLoading] = useState(true);<br \/>  useEffect(() =&gt; {<br \/>    let cancelled = false;<br \/>    fetch(&#8216;https:\/\/openapiv1.coinstats.app\/markets&#8217;, {<br \/>      headers: { &#8216;X-API-KEY&#8217;: import.meta.env.VITE_COINSTATS_KEY }<br \/>    })<br \/>      .then(res =&gt; res.json())<br \/>      .then(data =&gt; { if (!cancelled) { setStats(data); setLoading(false); } });<br \/>    return () =&gt; { cancelled = true; };<br \/>  }, []);<br \/>  return { stats, loading };<br \/>}<\/p>\n<h3>useChartData\u200a\u2014\u200aPrice history per\u00a0coin<\/h3>\n<p>\/\/ hooks\/useChartData.js<br \/>import { useState, useEffect, useCallback } from &#8216;react&#8217;;<br \/>const BASE = &#8216;https:\/\/openapiv1.coinstats.app&#8217;;<br \/>const HEADERS = { &#8216;X-API-KEY&#8217;: import.meta.env.VITE_COINSTATS_KEY };<br \/>export function useChartData(coinId, period = &#8216;1w&#8217;) {<br \/>  const [chartData, setChartData] = useState([]);<br \/>  const [loading, setLoading] = useState(false);<br \/>  const fetchChart = useCallback(async () =&gt; {<br \/>    if (!coinId) return;<br \/>    setLoading(true);<br \/>    let cancelled = false;<br \/>    try {<br \/>      const res = await fetch(<br \/>        `${BASE}\/coins\/${coinId}\/charts?period=${period}`,<br \/>        { headers: HEADERS }<br \/>      );<br \/>      const { result } = await res.json();<br \/>      const formatted = result.map(([time, price]) =&gt; ({<br \/>        time: new Date(time).toLocaleDateString(&#8216;en-US&#8217;, { month: &#8216;short&#8217;, day: &#8216;numeric&#8217; }),<br \/>        price<br \/>      }));<br \/>      if (!cancelled) setChartData(formatted);<br \/>    } finally {<br \/>      if (!cancelled) setLoading(false);<br \/>    }<br \/>    return () =&gt; { cancelled = true; };<br \/>  }, [coinId, period]);<br \/>  useEffect(() =&gt; { fetchChart(); }, [fetchChart]);<br \/>  return { chartData, loading };<br \/>}<\/p>\n<p>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 \u2192 triggers useEffect \u2192 fires another fetch \u2192 infinite loop. With the wrong deps array, your chart refetches on every keystroke in the search\u00a0box.<\/p>\n<h3>useNews\u200a\u2014\u200aNews\u00a0feed<\/h3>\n<p>\/\/ hooks\/useNews.js<br \/>import { useState, useEffect } from &#8216;react&#8217;;export function useNews(limit = 6) {<br \/>  const [news, setNews] = useState([]);<br \/>  const [loading, setLoading] = useState(true);  useEffect(() =&gt; {<br \/>    let cancelled = false;<br \/>    fetch(`https:\/\/openapiv1.coinstats.app\/news?limit=${limit}`, {<br \/>      headers: { &#8216;X-API-KEY&#8217;: import.meta.env.VITE_COINSTATS_KEY }<br \/>    })<br \/>      .then(res =&gt; res.json())<br \/>      .then(data =&gt; { if (!cancelled) { setNews(data.news); setLoading(false); } });<br \/>    return () =&gt; { cancelled = true; };<br \/>  }, [limit]);  return { news, loading };<br \/>}<\/p>\n<h3>Section 1: Global Market\u00a0Stats<\/h3>\n<p>\/\/ components\/MarketStats.jsx<br \/>import { useMarketStats } from &#8216;..\/hooks\/useMarketStats&#8217;;<br \/>function StatCard({ label, value, change }) {<br \/>  return (<br \/>    &lt;div className=&#8221;bg-[#161b22] rounded-xl p-5 border border-[#30363d]&#8221;&gt;<br \/>      &lt;p className=&#8221;text-[#8b949e] text-sm mb-1&#8243;&gt;{label}&lt;\/p&gt;<br \/>      &lt;p className=&#8221;text-white text-2xl font-semibold&#8221;&gt;{value}&lt;\/p&gt;<br \/>      {change !== undefined &amp;&amp; (<br \/>        &lt;p className={`text-sm mt-1 ${change &gt;= 0 ? &#8216;text-green-400&#8217; : &#8216;text-red-400&#8217;}`}&gt;<br \/>          {change &gt;= 0 ? &#8216;+&#8217; : &#8221;}{change?.toFixed(2)}%<br \/>        &lt;\/p&gt;<br \/>      )}<br \/>    &lt;\/div&gt;<br \/>  );<br \/>}<br \/>export function MarketStats() {<br \/>  const { stats, loading } = useMarketStats();<br \/>  if (loading) return (<br \/>    &lt;div className=&#8221;grid grid-cols-2 md:grid-cols-4 gap-4&#8243;&gt;<br \/>      {[&#8230;Array(4)].map((_, i) =&gt; (<br \/>        &lt;div key={i} className=&#8221;bg-[#161b22] rounded-xl p-5 h-24 animate-pulse border border-[#30363d]&#8221; \/&gt;<br \/>      ))}<br \/>    &lt;\/div&gt;<br \/>  );<br \/>  return (<br \/>    &lt;div className=&#8221;grid grid-cols-2 md:grid-cols-4 gap-4&#8243;&gt;<br \/>      &lt;StatCard<br \/>        label=&#8221;Total Market Cap&#8221;<br \/>        value={`$${(stats.totalMarketCap \/ 1e12).toFixed(2)}T`}<br \/>        change={stats.marketCapChange}<br \/>      \/&gt;<br \/>      &lt;StatCard<br \/>        label=&#8221;24h Volume&#8221;<br \/>        value={`$${(stats.totalVolume \/ 1e9).toFixed(0)}B`}<br \/>      \/&gt;<br \/>      &lt;StatCard label=&#8221;BTC Dominance&#8221; value={`${stats.btcDominance?.toFixed(1)}%`} \/&gt;<br \/>      &lt;StatCard label=&#8221;Alt Dominance&#8221; value={`${stats.altDominance?.toFixed(1)}%`} \/&gt;<br \/>    &lt;\/div&gt;<br \/>  );<br \/>}<\/p>\n<p>The skeleton uses animate-pulse from Tailwind\u200a\u2014\u200asame dimensions as the real card, no layout shift when data arrives. No spinners anywhere in the app. Spinners are disorienting because the user can&#8217;t tell how much is loading. Skeletons show exactly what&#8217;s\u00a0coming.<\/p>\n<h3>Section 2: Coin Table with Sort and\u00a0Search<\/h3>\n<p>\/\/ components\/CoinTable.jsx<br \/>import { useState, useMemo } from &#8216;react&#8217;;<br \/>import { useCoinList } from &#8216;..\/hooks\/useCoinList&#8217;;<br \/>const COLUMNS = [<br \/>  { key: &#8216;rank&#8217;, label: &#8216;#&#8217; },<br \/>  { key: &#8216;name&#8217;, label: &#8216;Name&#8217; },<br \/>  { key: &#8216;price&#8217;, label: &#8216;Price&#8217; },<br \/>  { key: &#8216;priceChange1h&#8217;, label: &#8216;1h %&#8217; },<br \/>  { key: &#8216;priceChange1d&#8217;, label: &#8217;24h %&#8217; },<br \/>  { key: &#8216;priceChange1w&#8217;, label: &#8216;7d %&#8217; },<br \/>  { key: &#8216;marketCap&#8217;, label: &#8216;Market Cap&#8217; },<br \/>];<br \/>export function CoinTable({ onSelectCoin }) {<br \/>  const { coins, loading } = useCoinList(50);<br \/>  const [query, setQuery] = useState(&#8221;);<br \/>  const [sortKey, setSortKey] = useState(&#8216;rank&#8217;);<br \/>  const [sortDir, setSortDir] = useState(1); \/\/ 1 = asc, -1 = desc<br \/>  function handleSort(key) {<br \/>    if (key === sortKey) {<br \/>      setSortDir(sortDir * -1); \/\/ toggle direction<br \/>    } else {<br \/>      setSortKey(key);<br \/>      setSortDir(1);<br \/>    }<br \/>  }<br \/>  const filtered = useMemo(() =&gt; {<br \/>    const q = query.toLowerCase();<br \/>    return coins.filter(c =&gt;<br \/>      c.name.toLowerCase().includes(q) || c.symbol.toLowerCase().includes(q)<br \/>    );<br \/>  }, [coins, query]);<br \/>  const sorted = useMemo(() =&gt; {<br \/>    return [&#8230;filtered].sort((a, b) =&gt; {<br \/>      const aVal = a[sortKey] ?? 0;<br \/>      const bVal = b[sortKey] ?? 0;<br \/>      if (typeof aVal === &#8216;string&#8217;) return sortDir * aVal.localeCompare(bVal);<br \/>      return sortDir * (aVal &#8211; bVal);<br \/>    });<br \/>  }, [filtered, sortKey, sortDir]);<br \/>  if (loading) return (<br \/>    &lt;div className=&#8221;space-y-2&#8243;&gt;<br \/>      {[&#8230;Array(10)].map((_, i) =&gt; (<br \/>        &lt;div key={i} className=&#8221;h-12 bg-[#161b22] rounded animate-pulse border border-[#30363d]&#8221; \/&gt;<br \/>      ))}<br \/>    &lt;\/div&gt;<br \/>  );<br \/>  return (<br \/>    &lt;div&gt;<br \/>      &lt;input<br \/>        type=&#8221;text&#8221;<br \/>        placeholder=&#8221;Search coin or symbol&#8230;&#8221;<br \/>        value={query}<br \/>        onChange={e =&gt; setQuery(e.target.value)}<br \/>        className=&#8221;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&#8243;<br \/>      \/&gt;<br \/>      &lt;div className=&#8221;overflow-x-auto&#8221;&gt;<br \/>        &lt;table className=&#8221;w-full text-sm&#8221;&gt;<br \/>          &lt;thead&gt;<br \/>            &lt;tr className=&#8221;text-[#8b949e] border-b border-[#30363d]&#8221;&gt;<br \/>              {COLUMNS.map(col =&gt; (<br \/>                &lt;th<br \/>                  key={col.key}<br \/>                  onClick={() =&gt; handleSort(col.key)}<br \/>                  className=&#8221;py-3 px-4 text-left cursor-pointer select-none hover:text-white transition-colors&#8221;<br \/>                &gt;<br \/>                  {col.label}<br \/>                  {sortKey === col.key &amp;&amp; (sortDir === 1 ? &#8216; \u2191&#8217; : &#8216; \u2193&#8217;)}<br \/>                &lt;\/th&gt;<br \/>              ))}<br \/>            &lt;\/tr&gt;<br \/>          &lt;\/thead&gt;<br \/>          &lt;tbody&gt;<br \/>            {sorted.map(coin =&gt; (<br \/>              &lt;tr<br \/>                key={coin.id}<br \/>                onClick={() =&gt; onSelectCoin(coin)}<br \/>                className=&#8221;border-b border-[#21262d] hover:bg-[#161b22] cursor-pointer transition-colors&#8221;<br \/>              &gt;<br \/>                &lt;td className=&#8221;py-3 px-4 text-[#8b949e]&#8221;&gt;{coin.rank}&lt;\/td&gt;<br \/>                &lt;td className=&#8221;py-3 px-4&#8243;&gt;<br \/>                  &lt;div className=&#8221;flex items-center gap-2&#8243;&gt;<br \/>                    &lt;img src={coin.icon} alt={coin.name} className=&#8221;w-6 h-6 rounded-full&#8221; \/&gt;<br \/>                    &lt;span className=&#8221;text-white font-medium&#8221;&gt;{coin.name}&lt;\/span&gt;<br \/>                    &lt;span className=&#8221;text-[#8b949e]&#8221;&gt;{coin.symbol}&lt;\/span&gt;<br \/>                  &lt;\/div&gt;<br \/>                &lt;\/td&gt;<br \/>                &lt;td className=&#8221;py-3 px-4 text-white font-mono&#8221;&gt;<br \/>                  ${coin.price &gt;= 1 ? coin.price.toLocaleString() : coin.price.toFixed(6)}<br \/>                &lt;\/td&gt;<br \/>                {[&#8216;priceChange1h&#8217;, &#8216;priceChange1d&#8217;, &#8216;priceChange1w&#8217;].map(k =&gt; (<br \/>                  &lt;td key={k} className={`py-3 px-4 font-mono ${coin[k] &gt;= 0 ? &#8216;text-green-400&#8217; : &#8216;text-red-400&#8217;}`}&gt;<br \/>                    {coin[k] &gt;= 0 ? &#8216;+&#8217; : &#8221;}{coin[k]?.toFixed(2)}%<br \/>                  &lt;\/td&gt;<br \/>                ))}<br \/>                &lt;td className=&#8221;py-3 px-4 text-[#8b949e]&#8221;&gt;<br \/>                  ${(coin.marketCap \/ 1e9).toFixed(1)}B<br \/>                &lt;\/td&gt;<br \/>              &lt;\/tr&gt;<br \/>            ))}<br \/>          &lt;\/tbody&gt;<br \/>        &lt;\/table&gt;<br \/>      &lt;\/div&gt;<br \/>    &lt;\/div&gt;<br \/>  );<br \/>}<\/p>\n<p>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&#8217;t re-run on unrelated renders\u200a\u2014\u200aonly when coins, query, sortKey, or sortDir actually\u00a0change.<\/p>\n<h3>Section 3: The Inline Price\u00a0Chart<\/h3>\n<p>The chart appears inline when the user clicks a coin row. It doesn\u2019t navigate anywhere. onSelectCoin in the parent sets a state variable; the chart renders conditionally below the\u00a0table.<\/p>\n<p>\/\/ components\/PriceChart.jsx<br \/>import { useState, useMemo } from &#8216;react&#8217;;<br \/>import {<br \/>  AreaChart, Area, XAxis, YAxis, Tooltip,<br \/>  ResponsiveContainer, defs, linearGradient, stop<br \/>} from &#8216;recharts&#8217;;<br \/>import { useChartData } from &#8216;..\/hooks\/useChartData&#8217;;<br \/>const PERIODS = [&#8216;1d&#8217;, &#8216;1w&#8217;, &#8216;1m&#8217;, &#8216;3m&#8217;, &#8216;6m&#8217;, &#8216;1y&#8217;];<br \/>export function PriceChart({ coin }) {<br \/>  const [period, setPeriod] = useState(&#8216;1w&#8217;);<br \/>  const { chartData, loading } = useChartData(coin.id, period);<br \/>  \/\/ Determine price direction for the selected period<br \/>  const isUp = useMemo(() =&gt; {<br \/>    if (chartData.length &lt; 2) return true;<br \/>    return chartData[chartData.length &#8211; 1].price &gt;= chartData[0].price;<br \/>  }, [chartData]);<br \/>  const color = isUp ? &#8216;#22c55e&#8217; : &#8216;#ef4444&#8217;; \/\/ green-500 \/ red-500<br \/>  return (<br \/>    &lt;div className=&#8221;bg-[#161b22] rounded-xl p-5 border border-[#30363d] mt-2&#8243;&gt;<br \/>      &lt;div className=&#8221;flex items-center justify-between mb-4&#8243;&gt;<br \/>        &lt;div&gt;<br \/>          &lt;span className=&#8221;text-white font-semibold text-lg&#8221;&gt;{coin.name}&lt;\/span&gt;<br \/>          &lt;span className=&#8221;text-[#8b949e] ml-2&#8243;&gt;{coin.symbol}&lt;\/span&gt;<br \/>        &lt;\/div&gt;<br \/>        &lt;div className=&#8221;flex gap-1&#8243;&gt;<br \/>          {PERIODS.map(p =&gt; (<br \/>            &lt;button<br \/>              key={p}<br \/>              onClick={() =&gt; setPeriod(p)}<br \/>              className={`px-3 py-1 rounded text-sm uppercase transition-colors ${<br \/>                period === p<br \/>                  ? &#8216;bg-blue-600 text-white&#8217;<br \/>                  : &#8216;text-[#8b949e] hover:text-white&#8217;<br \/>              }`}<br \/>            &gt;<br \/>              {p}<br \/>            &lt;\/button&gt;<br \/>          ))}<br \/>        &lt;\/div&gt;<br \/>      &lt;\/div&gt;<br \/>      {loading ? (<br \/>        &lt;div className=&#8221;h-64 animate-pulse bg-[#0d1117] rounded&#8221; \/&gt;<br \/>      ) : (<br \/>        &lt;ResponsiveContainer width=&#8221;100%&#8221; height={260}&gt;<br \/>          &lt;AreaChart data={chartData}&gt;<br \/>            &lt;defs&gt;<br \/>              &lt;linearGradient id=&#8221;chartGradient&#8221; x1=&#8221;0&#8243; y1=&#8221;0&#8243; x2=&#8221;0&#8243; y2=&#8221;1&#8243;&gt;<br \/>                &lt;stop offset=&#8221;5%&#8221; stopColor={color} stopOpacity={0.3} \/&gt;<br \/>                &lt;stop offset=&#8221;95%&#8221; stopColor={color} stopOpacity={0} \/&gt;<br \/>              &lt;\/linearGradient&gt;<br \/>            &lt;\/defs&gt;<br \/>            &lt;XAxis<br \/>              dataKey=&#8221;time&#8221;<br \/>              tick={{ fill: &#8216;#8b949e&#8217;, fontSize: 11 }}<br \/>              axisLine={false}<br \/>              tickLine={false}<br \/>              interval=&#8221;preserveStartEnd&#8221;<br \/>            \/&gt;<br \/>            &lt;YAxis<br \/>              domain={[&#8216;auto&#8217;, &#8216;auto&#8217;]}<br \/>              tick={{ fill: &#8216;#8b949e&#8217;, fontSize: 11 }}<br \/>              axisLine={false}<br \/>              tickLine={false}<br \/>              tickFormatter={v =&gt; `$${v &gt;= 1000 ? (v \/ 1000).toFixed(1) + &#8216;k&#8217; : v.toFixed(2)}`}<br \/>            \/&gt;<br \/>            &lt;Tooltip<br \/>              contentStyle={{ background: &#8216;#161b22&#8217;, border: &#8216;1px solid #30363d&#8217;, borderRadius: 8 }}<br \/>              labelStyle={{ color: &#8216;#8b949e&#8217; }}<br \/>              formatter={v =&gt; [`$${v.toLocaleString()}`, &#8216;Price&#8217;]}<br \/>            \/&gt;<br \/>            &lt;Area<br \/>              type=&#8221;monotone&#8221;<br \/>              dataKey=&#8221;price&#8221;<br \/>              stroke={color}<br \/>              strokeWidth={2}<br \/>              fill=&#8221;url(#chartGradient)&#8221;<br \/>              dot={false}<br \/>            \/&gt;<br \/>          &lt;\/AreaChart&gt;<br \/>        &lt;\/ResponsiveContainer&gt;<br \/>      )}<br \/>    &lt;\/div&gt;<br \/>  );<br \/>}<\/p>\n<p>The gradient ID chartGradient is static\u200a\u2014\u200aif you render multiple charts at once, give each a unique ID. In this dashboard there&#8217;s only one chart visible at a time, so it&#8217;s not an\u00a0issue.<\/p>\n<p>isUp compares chartData[0].price against chartData[chartData.length &#8211; 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\u00a0needed.<\/p>\n<h3>Section 4: News\u00a0Feed<\/h3>\n<p>\/\/ components\/NewsFeed.jsx<br \/>import { useNews } from &#8216;..\/hooks\/useNews&#8217;;<br \/>function NewsCard({ item }) {<br \/>  const date = new Date(item.publishedAt).toLocaleDateString(&#8216;en-US&#8217;, {<br \/>    month: &#8216;short&#8217;, day: &#8216;numeric&#8217;, year: &#8216;numeric&#8217;<br \/>  });<br \/>  return (<br \/>    &lt;a<br \/>      href={item.link}<br \/>      target=&#8221;_blank&#8221;<br \/>      rel=&#8221;noopener noreferrer&#8221;<br \/>      className=&#8221;bg-[#161b22] rounded-xl overflow-hidden border border-[#30363d] hover:border-blue-500 transition-colors flex flex-col&#8221;<br \/>    &gt;<br \/>      {item.imgUrl &amp;&amp; (<br \/>        &lt;img<br \/>          src={item.imgUrl}<br \/>          alt={item.title}<br \/>          className=&#8221;w-full h-40 object-cover&#8221;<br \/>          onError={e =&gt; { e.target.style.display = &#8216;none&#8217;; }}<br \/>        \/&gt;<br \/>      )}<br \/>      &lt;div className=&#8221;p-4 flex flex-col flex-1&#8243;&gt;<br \/>        &lt;p className=&#8221;text-white font-medium text-sm leading-snug line-clamp-2 mb-2&#8243;&gt;<br \/>          {item.title}<br \/>        &lt;\/p&gt;<br \/>        &lt;div className=&#8221;mt-auto flex items-center justify-between text-xs text-[#8b949e]&#8221;&gt;<br \/>          &lt;span&gt;{item.feedName}&lt;\/span&gt;<br \/>          &lt;span&gt;{date}&lt;\/span&gt;<br \/>        &lt;\/div&gt;<br \/>      &lt;\/div&gt;<br \/>    &lt;\/a&gt;<br \/>  );<br \/>}<br \/>export function NewsFeed() {<br \/>  const { news, loading } = useNews(6);<br \/>  if (loading) return (<br \/>    &lt;div className=&#8221;grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4&#8243;&gt;<br \/>      {[&#8230;Array(6)].map((_, i) =&gt; (<br \/>        &lt;div key={i} className=&#8221;h-64 bg-[#161b22] rounded-xl animate-pulse border border-[#30363d]&#8221; \/&gt;<br \/>      ))}<br \/>    &lt;\/div&gt;<br \/>  );<br \/>  return (<br \/>    &lt;div className=&#8221;grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4&#8243;&gt;<br \/>      {news.map(item =&gt; &lt;NewsCard key={item.id} item={item} \/&gt;)}<br \/>    &lt;\/div&gt;<br \/>  );<br \/>}<\/p>\n<p>onError on the image handles broken CDN URLs gracefully\u200a\u2014\u200ahides the image element instead of showing a broken icon. Small detail, but news images break often enough that it matters in production.<\/p>\n<h3>Composing the\u00a0App<\/h3>\n<p>\/\/ App.jsx<br \/>import { useState } from &#8216;react&#8217;;<br \/>import { MarketStats } from &#8216;.\/components\/MarketStats&#8217;;<br \/>import { CoinTable } from &#8216;.\/components\/CoinTable&#8217;;<br \/>import { PriceChart } from &#8216;.\/components\/PriceChart&#8217;;<br \/>import { NewsFeed } from &#8216;.\/components\/NewsFeed&#8217;;<br \/>export default function App() {<br \/>  const [selectedCoin, setSelectedCoin] = useState(null);<br \/>  return (<br \/>    &lt;div className=&#8221;min-h-screen bg-[#0d1117] text-white&#8221;&gt;<br \/>      &lt;div className=&#8221;max-w-7xl mx-auto px-4 py-8 space-y-10&#8243;&gt;<br \/>        &lt;header&gt;<br \/>          &lt;h1 className=&#8221;text-2xl font-bold&#8221;&gt;Crypto Dashboard&lt;\/h1&gt;<br \/>          &lt;p className=&#8221;text-[#8b949e] text-sm mt-1&#8243;&gt;Powered by CoinStats API&lt;\/p&gt;<br \/>        &lt;\/header&gt;<br \/>        &lt;section&gt;<br \/>          &lt;h2 className=&#8221;text-lg font-semibold mb-4&#8243;&gt;Market Overview&lt;\/h2&gt;<br \/>          &lt;MarketStats \/&gt;<br \/>        &lt;\/section&gt;<br \/>        &lt;section&gt;<br \/>          &lt;h2 className=&#8221;text-lg font-semibold mb-4&#8243;&gt;Top 50 Coins&lt;\/h2&gt;<br \/>          &lt;CoinTable onSelectCoin={setSelectedCoin} \/&gt;<br \/>          {selectedCoin &amp;&amp; &lt;PriceChart coin={selectedCoin} \/&gt;}<br \/>        &lt;\/section&gt;<br \/>        &lt;section&gt;<br \/>          &lt;h2 className=&#8221;text-lg font-semibold mb-4&#8243;&gt;Latest News&lt;\/h2&gt;<br \/>          &lt;NewsFeed \/&gt;<br \/>        &lt;\/section&gt;<br \/>      &lt;\/div&gt;<br \/>    &lt;\/div&gt;<br \/>  );<br \/>}<\/p>\n<p>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\u00a0be.<\/p>\n<h3>How Many API Calls Does the Dashboard Make?<\/h3>\n<p>At cold load, before any user interaction:<\/p>\n<p>CallEndpointFiresMarket statsGET \/marketsOn mountCoin listGET \/coins?limit=50On mountNewsGET \/news?limit=6On mountPrice chartGET \/coins\/{id}\/charts?period=1wOn coin\u00a0click<\/p>\n<p><strong>3 calls on load. 1 additional on first coin click. 1 per period\u00a0change.<\/strong><\/p>\n<p>CoinStats free tier allows 100 req\/min. The dashboard at idle uses 3 calls total. Even with aggressive period switching (6 periods \u00d7 5 coins), you\u2019d use ~33 calls\u200a\u2014\u200awell within\u00a0limits.<\/p>\n<h3>API Key\u00a0Security<\/h3>\n<p>In a Vite app, VITE_ prefixed variables are bundled into the JavaScript. Anyone who opens DevTools \u2192 Sources can read your\u00a0key.<\/p>\n<p>For this dashboard as a demo project, that\u2019s an acceptable tradeoff.<\/p>\n<p>For a public production deployment:<\/p>\n<p><strong>Option 1\u200a\u2014\u200aCloudflare Worker proxy:<\/strong> 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.<\/p>\n<p><strong>Option 2\u200a\u2014\u200aExpress backend:<\/strong> Same logic, more control, more infrastructure. Only worth it if you\u2019re already running a\u00a0server.<\/p>\n<p>The architecture change is minimal\u200a\u2014\u200areplace the base URL constant in your hooks with your proxy URL. Everything else stays identical.<\/p>\n<h3>FAQ<\/h3>\n<p><strong>Do I need a backend to use CoinStats API?<\/strong><\/p>\n<p>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\u00a0bundle.<\/p>\n<p><strong>How do I protect the API key in production?<\/strong><\/p>\n<p>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\u00a0minutes.<\/p>\n<p><strong>Why Recharts instead of Chart.js or\u00a0D3?<\/strong><\/p>\n<p>Recharts is declarative and built for React. Chart.js requires an imperative configuration model that fights React\u2019s rendering cycle. D3 is powerful for custom visualizations but overkill for a standard price area chart. Recharts ships ResponsiveContainer which handles resize automatically\u200a\u2014\u200ano ResizeObserver wiring\u00a0needed.<\/p>\n<p><strong>Why no TypeScript?<\/strong><\/p>\n<p>Deliberate choice for this project. The API responses are well-structured and the codebase is small enough that the overhead of maintaining types doesn\u2019t pay off. In a larger app or a team project, TypeScript makes\u00a0sense.<\/p>\n<p><strong>Does this work with React\u00a019?<\/strong><\/p>\n<p>Yes. All hooks used\u200a\u2014\u200auseState, useEffect, useMemo, useCallback\u200a\u2014\u200aare stable across React 16+. The cancelled flag cleanup pattern works identically in React\u00a019.<\/p>\n<p><strong>Can I add portfolio tracking without a\u00a0backend?<\/strong><\/p>\n<p>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\u00a0backend.<\/p>\n<h3>Wrapping Up<\/h3>\n<p>Four data sections. Four hooks. One\u00a0API.<\/p>\n<p>The architecture decisions here aren\u2019t clever\u200a\u2014\u200athey\u2019re the minimum viable structure for a dashboard that doesn\u2019t 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\u00a0chart.<\/p>\n<p><a href=\"https:\/\/coinstats.app\/api-docs\/\">CoinStats API<\/a> handles the rest: coins, charts, market context, and news from one base URL, on a free tier that doesn\u2019t require a credit\u00a0card.<\/p>\n<p>The full source is available on GitHub\u200a\u2014\u200aclone it, break it, and build something better: <a href=\"https:\/\/github.com\/Kevinelectronics\/cryptodashboard\">github.com\/Kevinelectronics\/cryptodashboard<\/a><\/p>\n<p><em>Looking for technical content for your company? I can help\u200a\u2014\u200a<\/em><a href=\"https:\/\/www.linkedin.com\/in\/kevin-meneses-gonzalez\/\"><em>LinkedIn<\/em><\/a><em> \u00b7 <\/em><a href=\"mailto:kevinmenesesgonzalez@gmail.com\"><em>kevinmenesesgonzalez@gmail.com<\/em><\/a><\/p>\n<p><a href=\"https:\/\/medium.com\/coinmonks\/how-i-built-a-real-time-crypto-dashboard-with-coinstats-api-and-react-80fc4b2e0f67\">How I Built a Real-Time Crypto Dashboard with CoinStats API and React<\/a> was originally published in <a href=\"https:\/\/medium.com\/coinmonks\">Coinmonks<\/a> on Medium, where people are continuing the conversation by highlighting and responding to this story.<\/p>","protected":false},"excerpt":{"rendered":"<p>Most crypto APIs are either free and broken, or reliable and expensive. If you\u2019ve tried to build something real with the usual suspects: CoinGecko rate-limits you at 10\u201330 req\/min before you even have a\u00a0key,CoinMarketCap puts historical data behind a $29+\/month paywall,Binance API only knows assets that trade on\u00a0Binance. You hit the wall before the first [&hellip;]<\/p>\n","protected":false},"author":0,"featured_media":157225,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[2],"tags":[],"class_list":["post-157224","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-interesting"],"_links":{"self":[{"href":"https:\/\/mycryptomania.com\/index.php?rest_route=\/wp\/v2\/posts\/157224"}],"collection":[{"href":"https:\/\/mycryptomania.com\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/mycryptomania.com\/index.php?rest_route=\/wp\/v2\/types\/post"}],"replies":[{"embeddable":true,"href":"https:\/\/mycryptomania.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=157224"}],"version-history":[{"count":0,"href":"https:\/\/mycryptomania.com\/index.php?rest_route=\/wp\/v2\/posts\/157224\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/mycryptomania.com\/index.php?rest_route=\/wp\/v2\/media\/157225"}],"wp:attachment":[{"href":"https:\/\/mycryptomania.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=157224"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/mycryptomania.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=157224"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/mycryptomania.com\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=157224"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}