DEV Community

Cover image for building a real-time cryptocurrency info table with React, MUI(material-ui) and coinmarket cap API
keyvan
keyvan

Posted on

building a real-time cryptocurrency info table with React, MUI(material-ui) and coinmarket cap API

We are building a real-time Crypto table that is responsive and shows lots of information about every cryptocurrency using the coinmarket cap API.The app has a simple express backend to fetch the data from the coinmarket cap.
you can checkout the full code
Table of contents:

preparation

make a folder named crypto-table open your terminal and run the commands:
Powershell:

mkdir crypto-table;cd crypto-table;code .
Enter fullscreen mode Exit fullscreen mode

bash:

mkdir cypto-table && cd crypto-table && code .
Enter fullscreen mode Exit fullscreen mode

that would make a folder and open the vscode

frontend

inside the crypto-table folder open a terminal and install React with CRA:

npx create-react-app frontend
Enter fullscreen mode Exit fullscreen mode

open the src folder and delete everything inside this folder except index.js.
now cd into the frontend folder and install @mui:

npm install @mui/material @emotion/styled @emotion/react react-transition-group
Enter fullscreen mode Exit fullscreen mode

emotion packages are necessary for mui to work

backend

our express backend will be a simple server just to fetch data from the coinmarket cap API.head over to the root folder(crypto-table)and make a folder named backend.inside this folder open a terminal and install express and axios and nodemon:

npm install express nodemon axios dotenv
Enter fullscreen mode Exit fullscreen mode

now we have installed the packages we need to build the project you should have a folder structure like this:

|-- crypto-table
|   |-- backend
|   |-- frontend
        |-- public
        |-- src
            |-- index.js
        |-- .gitignre
        |-- package-lock.json
        |-- package.json
        |-- README.md

Enter fullscreen mode Exit fullscreen mode

API key

visit the coinmarketcap website:

coinmarketcap api site

Click the GET YOUR API KEY NOW button. sign up on the website and verify your email. after finish signing up and confirming your email address, it will redirect you to your account page.
if you didn't redirect to the account page visit this link and login.

account page
(it has a generous free plan with 333 calls a day)

when you move the mouse over the API key section it shows a button that copies the key to the clipboard. now you are all set to move to the next section

building the backend

inside the backend folder make two files: server.js and .env.
open the .env file, make a variable and paste your API key like so:

COINMARKETCAP_API='(your_api_key)'
Enter fullscreen mode Exit fullscreen mode

now let's build our express server.
import express and make a simple server that listens on port 4000:

reuqire('dotenv').config();
const express = require('express');
const app = express();
app.use(express.json());
app.get('/', (req, res) => {
  res.send('GET REQUEST');
});

app.listen(400, () => {
  console.log('server is running');
});
Enter fullscreen mode Exit fullscreen mode

on terminal cd inside backend and type:

nodemon server.js
Enter fullscreen mode Exit fullscreen mode

checkout localhost:4000 you should see a text on the screen that says GET REQUEST
the coinmarket cap documentions has lots of information on different endpoints. we'll use
the v1/cryptocurrency/listing/latest endpoint, it returns a sorted list based on the highest market_cap.basically it is the same listing order on their front page.
create an instance of axios with basicURL and your API key.

const api = axios.create({
  method: 'GET',
  baseURL: 'https://pro-api.coinmarketcap.com/v1/cryptocurrency',
  headers: {
    'X-CMC_PRO_API_KEY': process.env.COINMARKETCAP_API_KEY,
    Accept: 'application/json',
    'Accept-Encoding': 'deflate, gzip',
  },
});
Enter fullscreen mode Exit fullscreen mode

The X-CMC_PRO_API_KEY is the coinmarketcap's authentication header parameter.
set the route as /api.now call the API inside the get request
the response has two parameters: status and data.check out the status parameter, it has useful info that you can use in your logic

app.get('/api', (req, res) => {
  api('/listings/latest?limit=20')
    .then(response => response.data)
    .then(value => res.json(value.data))
    .catch(err => console.log(err));
});
Enter fullscreen mode Exit fullscreen mode

visit the localhost:4000 you should see a list of cryptocurrencies

list of currencies
(I am using the json-viewer extension. this is the link you can download the extension from webstore.)

now we have all we need on the server-side. your server.js code should look like this:

require('dotenv').config();
const express = require('express');
const axios = require('axios');
const app = express();
app.use(express.json());

const api = axios.create({
  method: 'GET',
  baseURL: 'https://pro-api.coinmarketcap.com',
  headers: {
    'X-CMC_PRO_API_KEY': `${process.env.COINMARKETCAP_API_KEY}`,
    Accept: 'application/json',
    'Accept-Encoding': 'deflate, gzip',
  },
});
app.get('/api', (req, res) => {
  api('/v1/cryptocurrency/listings/latest?limit=20')
    .then(response => response.data)
    .then(value => res.json(value.data))
    .catch(err => console.log(err));
});
app.listen(4000, () => {
  console.log('express server');
});
Enter fullscreen mode Exit fullscreen mode

the limit at the end gives us the first 20 elements of the list. by default, it returns a list of 100 elements. there is a limit to the free plan on coinmarket API, although it is a generous free plan, I recommend implementing a cache mechanism(like with Redis) and fetching data from API on a specific time interval, then sending back the cache data to the client

building the frontend

create a new file named App.js
we want to use a dark theme for our table. the default theme mode on mui is light, so we need to create a theme and set it to dark mode.
import all necessary dependencies inside the App.js:

//App.js
import React from 'react';
import { createTheme, ThemeProvider } from '@mui/material';
Enter fullscreen mode Exit fullscreen mode

create a theme with dark mode :

//App.js
const theme = createTheme({
  palette: {
    mode: 'dark',
  },
});
Enter fullscreen mode Exit fullscreen mode

now use the ThemeProvider to inject the dark mode. your App.js code should look like this:

import { createTheme, ThemeProvider } from '@mui/material';
import React from 'react';
const theme = createTheme({
  palette: {
    mode: 'dark',
  },
});
export default function App() {
  return (
    <ThemeProvider theme={theme}>
      <div>test</div>
    </ThemeProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

use the npm start command to spin up the React server. visit localhost:3000you should see a text on the screen that says test.
we are all set to build our Table component.

Table component

we'll use the Table component of mui.under the hood mui uses the native table element. create two files named CoinTable.js, CoinBody.js(that's where the table body resides). first of all import the necessary components:
(we'll show the skeleton component while the data is loading)

//ConinTable.js
import React, { useEffect, useState } from 'react';
import TableContainer from '@mui/material/TableContainer';
import Table from '@mui/material/Table';
import {
  Fade,
  Paper,
  Skeleton,
  TableBody,
  TableCell,
  TableHead,
  TablePagination,
  TableRow,
  Typography,
} from '@mui/material';
import ArrowDropUpIcon from '@mui/icons-material/ArrowDropUp';
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
import CoinBody from './CoinBody';
Enter fullscreen mode Exit fullscreen mode

in this example, we'll use 8 columns of data. let's see the code and we talk about every step:

//CoinTable.js
export default function CoinTable() {
  return (
    <Paper>
      <TableContainer>
        <Table sx={{ minWidth: 700, '& td': { fontWeight: 700 } }}>
          <TableHead>
            <TableRow>
              <TableCell>#</TableCell>
              <TableCell>name</TableCell>
              <TableCell align="right">Price</TableCell>
              <TableCell align="right">24h %</TableCell>
              <TableCell align="right">7d %</TableCell>
              <TableCell align="right">Market Cap</TableCell>
              <TableCell align="right">Volume(24h)</TableCell>
              <TableCell align="right">Circulating supply</TableCell>
            </TableRow>
          </TableHead>
          <TableBody>
            <CoinTableBody />
          </TableBody>
        </Table>
      </TableContainer>
      <TablePagination
        component={'div'}
        rowsPerPageOptions={[5, 10, 20]}
        rowsPerPage={5}
        onRowsPerPageChange={e => ''}
        count={20}
        page={0}
        onPageChange={(e, newPage) => ''}
      />
    </Paper>
  );
}
Enter fullscreen mode Exit fullscreen mode

there are lots of data and functionality going on inside the table body.make a file named CoinTableBody.js.

//CoinBody.js
export default function CoinTableBody() {
  return (
    <TableRow>
      <TableCell>1</TableCell>
      <TableCell align="right">bitcoin</TableCell>
      <TableCell align="right">$42000</TableCell>
      <TableCell align="right">1%</TableCell>
      <TableCell align="right">2%</TableCell>
      <TableCell align="right">$2000000</TableCell>
      <TableCell align="right">$3000000</TableCell>
      <TableCell align="right">$19200000</TableCell>
      <TableCell align="right">$19200000</TableCell>
    </TableRow>
  );
}
Enter fullscreen mode Exit fullscreen mode
  • Paper: it gives us a nice surface and boxshadow.the default color is #121212
  • TableContainer:it is a wrapper around the table that gives the table a fluid width
  • Table: the native table element.as you notice I gave it a minWidth so it wouldn't shrink any fewer than700pixels.I didn't specify any unit that is because mui by default uses pixel for any unitless numbers. if you wish to use rem or any other units you should pass your value as a string like so: sx={{ minWidth: "60rem"}}.the second parameter set the fontWeight on all td elements inside the Table component to 700.if you want to set the sticky header on table you need to specify a maxHeight css property on TableContainer and a pass stickyHeader prop to Table component.
  • TableHead:thead native element
  • TableRow:tr native elment
  • TableCell:td native element.notice we set the TableCell component to align="right" except the first one.it looks much better but it's a matter of opinion you can change it if you want.
  • TableBody:the tbody native element. that's where the data resign and changes periodically
  • TablePagination: it is our pagination control with all the good stuff. notice we have implemented the pagination outside the TableContainer because we don't want the pagination to be on the same scrolling area as the table. now the pagination won't scroll with the table on small devices.it has its own scroll bar. use the chrome devtools and toggle the device toolbar, you'll see in small devices the pagination won't scroll with the table while scrolling horizontally. we have hardcoded the count just for now.rowsPerPageOptions receive an array with options that the user can choose from.rowsPerPage is the initial number of rows per page.onRowsPerPageChange and onPageChagne are the functions that we leverage to change our Table UI.

update the App.js file:

import { createTheme, ThemeProvider } from '@mui/material';
import React from 'react';
import Table from './CoinTable';
let theme = createTheme({
  palette: {
    mode: 'dark',
  },
});
export default function App() {
  return (
    <ThemeProvider theme={theme}>
      <Table />
    </ThemeProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

right now our markup is finished we have the look and it's time to introduce state and fetch data from our server.

custom hook

create file named hooks-helpers.js.inside this file we implement the hook and a helper function.
open the hooks-helpers.js file. let's build a custom hook that fetches data from API and return the data and an isLoading parameter.

//hooks-helpers.js

function useCoinMarket() {
  const [state, setState] = useState({ data: [], isLoading: true });
  const updateState = data => {
    setState(state => ({
      data: data ? data : state.data,
      isLoading: false,
    }));
  };
  async function init() {
    try {
      const res = await fetch('/api');
      const data = await res.json();
      updateState(data);
    } catch (err) {
      console.log(err);
    }
  }
  useEffect(() => {
    init();
    const id = setInterval(() => {
      init();
    }, 1 * 60 * 1000);
    return () => clearInterval(id);
  }, []);
  return state;
}
Enter fullscreen mode Exit fullscreen mode

notice we have set two fields for the state data, isLoading.the isLoading is true initially so the table would show a skeleton and when the promise is fulfilled, we set the isLoading to false.also you can set a isError property to show some info on screen when there is an error and send a request to an analytic endpoint to log your errors.
we use setInterval to call init every 1 minute to update the table.(change the time as you wish)

this is a side note in regards to different approaches toward calling a function immediately and setting a time interval on the callee, you can skip this part if you want.
there are other interesting ways to achieve immediately calling a function and setting a time interval:

1.using a setTimeout:

function mysetInterval(func, time) {
  func();
  return setTimeout(func, time);
}

we call the mysetInteval with the init function and clearinterval with the return value

  1. using setInterval with IIFE :
setInterval(
  (function mysetInteravl() {
    init();
    return mysetInterval;
  })(),
  1 * 60 * 1000 //you can specify your time
);

it immediately calls the function and then returns itself to be called on the next time interval

Add two state hooks for page and rowsPerPage to handle the pagination state.
pass them to onRowsPerPageChange and onPageChange.notice the onPageChange props callback have two arguments.the second argument is the new page sets by the user.pass rowsPerPage,page to CoinBody component.we have to somehow send the data length to the pagination component(the count prop).in order to achieve that,make a new state hook (dataLength,setDataLength) and pass down the setDataLenght to the coninTableBody and pass the dataLength to count prop.

side note:
We could use the useCoinMarket hook inside the CointTable component. The benefit we gain, is that we have moved the state and data fetching where exactly it's needed(it is widely known as co-locating). the pagination component needs the dataLength and we exchange the data between the paginitaion and CoinTableBody through the parent component(CoinTable)
When we move the data where needed, we can avoid unnecessary re-renders. in this example, only the CoinBody component re-renders on every data fetching, while it won't cause any re-renders on the CointTable and Pagination components

//imports
//.
//.
export default function CoinTable() {
  const [rowsPerPage, setRowsPerPage] = useState(10);
  const [page, setPage] = useState(0);
  const [dataLength, setDataLength] = useState(0);
  return (
    <Paper>
      <TableContainer>
        <Table sx={{ minWidth: 700, '& td': { fontWeight: 700 } }}>
          <TableHead>
            <TableRow>
              <TableCell>#</TableCell>
              <TableCell colSpan={2}>name</TableCell>
              <TableCell align="right">Price</TableCell>
              <TableCell align="right">24h %</TableCell>
              <TableCell align="right">7d %</TableCell>
              <TableCell align="right">Market Cap</TableCell>
              <TableCell align="right">Volume(24h)</TableCell>
              <TableCell align="right">Circulating supply</TableCell>
            </TableRow>
          </TableHead>
          <TableBody>
            <CoinTableBody
              rowsPerpage={rowsPerpage}
              page={page}
              setDataLength={setDataLength}
            />
          </TableBody>
        </Table>
      </TableContainer>
      <TablePagination
        component={'div'}
        rowsPerPageOptions={[5, 10, 20]}
        rowsPerPage={5}
        count={dataLength}
        onRowsPerPageChange={e => {
          setRowsPerPage(parseInt(e.target.value));
          setPage(0);
        }}
        page={page}
        onPageChange={(e, newPage) => {
          setPage(newPage);
        }}
      />
    </Paper>
  );
}
Enter fullscreen mode Exit fullscreen mode

now import the custom hook inside the CoinBody.js file.
on the CoinTableBody component we need to extract the proportion of the data based on the number of page and rowsPerPage.isLoading parameter is used to show a skeleton while data is loading.insdie CoinBody make a componenet named BodySkeleton.pass rowsPerPAge and number of heads.

//CoinBody.js
 const CoinTableBody=memo(({ rowsPerpage, page, setDataLength })=> {
  const { data, isLoading, update } = useCoinMarket();
  const dataSliced = data.slice(page * rowsPerPage, (page + 1) * rowsPerPage);
  useEffect(() => {
    setDataLength(data.length);
  }, [data.length]);

  return (
    <TableBody>
      {isLoading ? (
        <BodySkeleton rows={rowsPerPage} heads={8} />
      ) : (
        dataSliced.map(row => (
          <TableRow>
            <TableCell>bitcoin</TableCell>
            <TableCell align="right">$42000</TableCell>
            <TableCell align="right">3%</TableCell>
            <TableCell align="right">2%</TableCell>
            <TableCell align="right">$19200000</TableCell>
            <TableCell align="right">$19200000</TableCell>
          </TableRow>
        ))
      )}
    </TableBody>
  );
})
export default CoinTableBody
Enter fullscreen mode Exit fullscreen mode

we make two arrays based on the rows and head props to map over them and show the skeleton

//CoinBody.js

const BodySkeleton = ({ rows, heads }) => {
  const rowArray = Array(rows).fill(null);
  const cellArray = Array(heads).fill(null);
  return rowArray.map((_, index) => (
    <TableRow key={index}>
      {cellArray.map((_, index) => (
        <TableCell key={index} align={index === 1 ? 'left' : 'right'}>
          {index === 1 ? (
            <Box sx={{ display: 'flex', alignItems: 'center' }}>
              <Skeleton variant="circular" width={25} height={25} sx={{ mr: 1 }} />
              <Skeleton width={100} />
            </Box>
          ) : (
            <Skeleton />
          )}
        </TableCell>
      ))}
    </TableRow>
  ));
};
Enter fullscreen mode Exit fullscreen mode

the body would house lots of data and components so it is wise to move them into a component. make a file named BodyRow.js and change the CoinTableBody like so:

//CoinTableBody.js
 const CoinTableBody = memo(({ rowsPerPage, page, setDataLength }) => {
  const { data, isLoading } = useCoinMarket();
  const dataSliced = data.slice(page * rowsPerPage, (page + 1) * rowsPerPage);
  useEffect(() => {
    setDataLength(data.length);
  }, [data.length]);
  console.log('body');
  return (
    <TableBody>
      {isLoading ? (
        <BodySkeleton rows={rowsPerPage} heads={8} />
      ) : (
        dataSliced.map(row => <BodyRow key={row.id} row={row} />)
      )}
    </TableBody>
  );
});
export default CoinTableBody
Enter fullscreen mode Exit fullscreen mode

the API provides us substantial information about all aspects of cryptocurrency. In this example we are going to show 8 columns of information such as price,24 hours change,7 days change, circulating supply, market cap,24h volumne(make sure to check out other properties too)
there is not much to do in regards to processing the numbers.We show two digits after the decimal point(toFixed(2)).price, market cap, and circulating supply need to be formatted as a currency.
we use the Intl.NumberFormat object hence the numberFormat function(we'll get to it).on percent_change_24h and percent_change_7d,based on being negative or positive, the renderPercentages return our percentages in red or green color with down or up arrows. I've used the default mui theme colors success.main and error.main.check out other fields on their
default themeproperties.
switchTransition with the fade component gives us a nice fading transition effect. Whenever the key property on the fade component changes, the switchTransition triggers the in prop of the fade component.
on two table cells we have used sx with [theme.breakpoints.down('md')].it would introduce a breakpoint that triggers under the 900px width devices.it will set the row number,name and avatar in sticky position so the user can scroll horizantally and see the name alongside other colums.when using sx as a function we can use the theme object.
(https://s2.coinmarketcap.com/static/img/coins/64x64/ is an endpoint on coinmarketcap for coin icons, just add the coin id at the end)

//BodyRow.js
export default functin BodyRow({ row }) {
  const { name, quote } = row;
  const USD = quote.USD;
  const price = numberFormat(USD.price);
  const percent_24 = USD.percent_change_24h.toFixed(2);
  const percent_7d = USD.percent_change_7d.toFixed(2);
  const circulating_supply = numberFormat(row.circulating_supply,{style:'decimal'});
  const marketCap = numberFormat(USD.market_cap, {
    notation: 'compact',
    compactDisplay: 'short',
  });
  const volume_24 = numberFormat(USD.volume_24h);
  const renderPercentage = num => {
    return num > 0 ? (
      <Box
        display="flex"
        justifyContent="flex-end"
        alignItems="center"
        color={'success.main'}
      >
        <ArrowDropUpIcon color={'success'} />
        <span>{num}%</span>
      </Box>
    ) : (
      <Box
        display={'flex'}
        justifyContent="flex-end"
        alignItems="center"
        color={'error.main'}
      >
        <ArrowDropDownIcon />
        <span> {num.replace('-', '')}%</span>
      </Box>
    );
  };
  return (
    <TableRow sx={{ '& td': { width: 20 } }}>
      <TableCell
         sx={theme => ({
          [theme.breakpoints.down('md')]: {
            position: 'sticky',
            left: 0,
            zIndex: 10,
            backgroundColor: '#121212',
          },
        })}
      >
        {row.cmc_rank}
      </TableCell>
      <TableCell
        padding="none"
        sx={theme => ({
          [theme.breakpoints.down('md')]: {
            position: 'sticky',
            left: 48,
            zIndex: 10,
            backgroundColor: '#121212',
          },
        })}
      >
        <Box sx={{ display: 'flex', alignItems: 'center' }}>
          <Avatar
            src={bit}
            sx={{
              width: 25,
              height: 25,
              mr: 1,
            }}
          />
          <span>
            {name}&nbsp;{row.symbol}
          </span>
        </Box>
      </TableCell>
      <SwitchTransition>
        <Fade key={price}>
          <TableCell align="right">{price}</TableCell>
        </Fade>
      </SwitchTransition>
      <SwitchTransition>
        <Fade key={percent_24}>
          <TableCell align="right">{renderPercentage(percent_24)}</TableCell>
        </Fade>
      </SwitchTransition>
      <SwitchTransition>
        <Fade key={percent_7d}>
          <TableCell align="right">{renderPercentage(percent_7d)}</TableCell>
        </Fade>
      </SwitchTransition>
      <TableCell align="right">{marketCap}</TableCell>

      <TableCell align="right">{volume_24}</TableCell>
      <TableCell align="right">
        {circulating_supply}&nbsp;{row.symbol}
      </TableCell>
    </TableRow>
  );
});
Enter fullscreen mode Exit fullscreen mode

numberFormatfunction returns the number in currency or decimal style.maximumFractionDigits has 3 conditions.

  1. numbers over 1 set to 2 digits after decimal point
  2. numbers with less than 4 digits return the same number of digits after the decimal point
  3. numbers with more than 4 digits return up to 8 digits after a decimal point there other interesting properties on this utility (a great tool for internationalization). We have implemented a default option while we can add an object as a second parameter to modify the default. (for example, on the market cap we set notaion:'compact',compactDisplay:'short', it will display the market cap in the short format followed by a B as in billions sign). we set the style of circulating supply to decimal to show the plain number
//hooks-helpers.js
function numberFormat(num, options) {
  let temp = 2;
  if (num < 1 && num > 0.0001) {
    temp = 4;
  }
  if (num < 0.0001) {
    temp = 8;
  }
  let defaultOptions = {
    style: 'currency',
    currency: 'USD',
    maximumFractionDigits: temp,
    minimumFractionDigits: 2,
    notation: 'standard',
    compactDisplay: 'long',
  };
  return new Intl.NumberFormat('en-US', { ...defaultOptions, ...options }).format(num);
}
Enter fullscreen mode Exit fullscreen mode

I'd be happy to hear from you, let's connect on Twitter

Discussion (5)

Collapse
bryce profile image
Bryce Dorn

Nice article! One note - now that Suspense has been officially released, you can use that for controlling what renders based on your loading state (with the fallback param). Makes things more declarative and readable!

Collapse
kevinkh89 profile image
keyvan Author

good point. I have already written the suspense version too .it is going to be a series that I will introduce sorting and then using the datagrid component.

Collapse
riskezwn profile image
riskezwn

Great post! Just a question maybe a little noob, what are the advantages of making the request to the api from a backend instead of from the frontend directly? Thanks!

Collapse
kevinkh89 profile image
keyvan Author

thank you @riskezwn .

  1. you need to deal with CORS policy.
    the coinmarketcap API docs:

    Making HTTP requests on the client side with Javascript is currently prohibited >through CORS configuration. This is to protect your API Key which should not be >visible to users of your application so your API Key is not stolen. Secure your API Key >by routing calls through your own backend service.

  2. exposing your API key

  3. any time a client visit the website or hit refresh there is a new call to the api endpoint.given the request limit(although you can subscribe to other plans), you could reach the limit in no time.by having a backend you can call the api on a specific time interval and cache the data then send the cache data back to the client.even on a free plan, that is 333 request per day, you can build a great info table

Collapse
riskezwn profile image
riskezwn

Thank you very much for your reply!