Create a COVID-19 tracker in React

Introduction

At the time of writing, there are 2,494,915 confirmed COVID-19 cases worldwide. Many more are expected, and a huge number of people are confined to their homes. It’s grim news, and it’ll take time to get better.

With that said, it’s not a bad time to try and improve your skills if you’re in the right headspace to do so. It’s completely understandable if not though, these are stressful times and there is no expectation that you should be doing anything than getting through this.

If you’d like to learn how to make a cool COVID-19 heatmap in React, read below! If you want to skip directly to the full code, click here

Project setup

To keep this simple, we’re going to use create-react-app to get started. Run the following commands:

npx create-react-app covid-tracker && cd covid-tracker

This will allow you to use create-react-app without having to install it to your system, create a project called covid-tracker and enter the directory.

You’ll see plenty of boilerplate code which you can ignore for now. Go to src/App.js and clear the code in the return () statement.

First let’s get hold of some data. To do this, we’ll use the free Corona API. I’m specifically using the /v2/countries endpoint. This shows the latitude and longitude of each country where a COVID-19 case is present, and some statistics.

To pull this data into our component, we want to load it on the first render so that we have access to the data in our map. To do so, we take advantage of the useEffect hook. This is pretty close to the lifecycle methods we previously had such as ComponentWillMount and ComponentDidMount.

Our effect will look like this:

const [getCases, setCases] = useState(undefined)
const [loading, setLoading] = useState(true)
/**
 * Get our data on the first render, and prevent from
 * fetching on subsequent renders. If our request fails
 * or takes too long, then clean up.
 */
useEffect(() => {
  let isCancelled = false
  let source = axios.CancelToken.source()
  function getFetchUrl() {
    return "https://corona.lmao.ninja/v2/countries"
  }
  async function fetchData() {
    let result
    if (!isCancelled) {
      result = await axios(getFetchUrl())
    }
    setCases(result.data)
    setLoading(false)
  }

  fetchData()

  return () => {
    isCancelled = true
    source.cancel("Cancelling in cleanup")
  }
}, [])

Let’s break this down. So firstly we declare that we’re using a hook, useEffect. Then we create a variable isCancelled. This will become useful down the line for cleaning up our operation.

If the operation isn’t cancelled, we then use axios (a popular data-fetching library) to asynchronously fetch our endpoint. We have to declare this as its own function inside the useEffect hook as asynchronous functions return a promise, which the hook doesn’t expect. Instead, the hook expects that either nothing is returned, or a function is returned.

In the future, using React Suspense will remove this issue but for now, this is the workaround.

Once the resource is fetched, we update our state with the data returned, and set loading to false.

We also have a function below this which acts as our cleanup. This effectively acts as ComponentWillUnmount and we use this to cancel our axios request mid-flight.

Finally, we pass an empty array as an optional argument to useEffect which prevents it from triggering each time the component renders.

Ok, so now we have some data. We now need to convert it to GeoJSON to be displayed in react-map-gl. To do this, we’re going to write a quick utility function to transform our current data into the appropriate format.

Create a folder called utils and add makeGeoJSON.js to it. The code is:

const makeGeoJSON = data => {
  return {
    type: "FeatureCollection",
    features: data.map(feature => {
      return {
        type: "Feature",
        properties: {
          id: feature.countryInfo?._id,
          value: feature.cases,
        },
        geometry: {
          type: "Point",
          coordinates: [feature.countryInfo.long, feature.countryInfo.lat],
        },
      }
    }),
  }
}

export default makeGeoJSON

This takes in our data as a variable, and we map over each item in the array to add its coordinates. We now have valid GeoJSON!

In our main script, we want to pass our data to our new utility function:

// Convert our JSON to GeoJSON
let data
if (!loading) {
  data = makeGeoJSON(getCases)
}

Finally, we want to add this to the map! First, add the following dependencies:

yarn add react-map-gl axios

Firstly, we need to set some default parameters for our map on its initialization:

// Set our initial map variables
const [viewport, setViewport] = useState({
  latitude: 55.8609825,
  longitude: -4.2488787,
  zoom: 4,
  width: "100vw",
  height: "100vh",
})

This simply sets the initial latitude and longitude to Glasgow, Scotland (where I live) but you can make it whatever you want. Then we set a zoom level (smaller being further our, larger being closer in).

Finally we set the default height and width which I’ve just made the whole page.

Now we have our map, we can render it like so:

  return (
  <div className="App">
    {loading && <h1>Loading</h1>}
    {!loading && (
      <ReactMapGL
        {...viewport}
        mapboxApiAccessToken={process.env.REACT_APP_MAPBOX_TOKEN}
        onViewportChange={(viewport) => setViewport(viewport)}
        mapStyle="mapbox://styles/mapbox/dark-v9"
      >
        <Source type="geojson" data={data}>
            <Layer {...heatMapLayer} />
        </Source>
      </ReactMapGL>
    )}
  </div>
);
}

This is fairly self-explanatory but as you can see:

We check the loading state, and display an appropriate holding message while we load our data.

If we are not loading the data, then we render the map with the default map variables, and pass it our token (which you can create for free on Mapbox).

We then add a method onViewportChange which is provided by react-map-gl and allows us to make the map interactive. It provides us with the viewport variable which contains the lat/lng/zoom etc and we simply update our state with that data.

Finally we add a mapStyle. There are many online but I just went with a simple dark theme from mapbox.

Once we have rendered the map, we then pass it a custom layer. This uses heatMapLayer which we will now create in our utils folder:

const MAX_ZOOM_LEVEL = 9

const heatMapLayer = {
  maxzoom: MAX_ZOOM_LEVEL,
  type: "heatmap",
  threshold: 0.03,
  radiusPixels: 30,
  paint: {
    // Increase the heatmap weight based on frequency and property magnitude
    "heatmap-weight": ["interpolate", ["linear"], ["get", "mag"], 0, 0, 6, 1],
    // Increase the heatmap color weight weight by zoom level
    // heatmap-intensity is a multiplier on top of heatmap-weight
    "heatmap-intensity": [
      "interpolate",
      ["linear"],
      ["zoom"],
      0,
      1,
      MAX_ZOOM_LEVEL,
      80,
    ],
    // Color ramp for heatmap.  Domain is 0 (low) to 1 (high).
    // Begin color ramp at 0-stop with a 0-transparancy color
    // to create a blur-like effect.
    "heatmap-color": [
      "interpolate",
      ["linear"],
      ["heatmap-density"],
      0,
      "rgba(10,0,0,0)",
      0.2,
      "rgb(100,0,0)",
      0.4,
      "rgb(120,0,0)",
      0.6,
      "rgb(1300,0,0)",
      0.8,
      "rgb(140,0,0)",
      2.1,
      "rgb(255,0, 0)",
    ],
    // Adjust the heatmap radius by zoom level
    "heatmap-radius": [
      "interpolate",
      ["linear"],
      ["zoom"],
      0,
      2,
      MAX_ZOOM_LEVEL,
      30,
    ],
    // Transition from heatmap to circle layer by zoom level
    "heatmap-opacity": ["interpolate", ["linear"], ["zoom"], 7, 1, 9, 0],
  },
}

export default heatMapLayer

This is from Uber’s example. I just customised it a bit for sizing and to have a red colour. You can easily customise this to your needs.

Your full code in App.js should look like this:

import axios from "axios"
import React, { useEffect, useState } from "react"
import ReactMapGL, { Layer, Source } from "react-map-gl"
import { heatMapLayer, makeGeoJSON } from "./utils"

function App() {
  const [getCases, setCases] = useState(undefined)
  const [loading, setLoading] = useState(true)

  // Set our initial map variables
  const [viewport, setViewport] = useState({
    latitude: 55.8609825,
    longitude: -4.2488787,
    zoom: 4,
    width: "100vw",
    height: "100vh",
  })

  /**
   * Get our data on the first render, and prevent from
   * fetching on subsequent renders. If our request fails
   * or takes too long, then clean up.
   */
  useEffect(() => {
    let isCancelled = false
    let source = axios.CancelToken.source()
    function getFetchUrl() {
      return "https://corona.lmao.ninja/v2/countries"
    }
    async function fetchData() {
      let result
      if (!isCancelled) {
        result = await axios(getFetchUrl())
      }
      setCases(result.data)
      setLoading(false)
    }

    fetchData()

    return () => {
      isCancelled = true
      source.cancel("Cancelling in cleanup")
    }
  }, [])

  // Convert our JSON to GeoJSON
  let data
  if (!loading) {
    data = makeGeoJSON(getCases)
  }

  return (
    <div className="App">
      {loading && <h1>Loading</h1>}
      {!loading && (
        <ReactMapGL
          {...viewport}
          mapboxApiAccessToken={process.env.REACT_APP_MAPBOX_TOKEN}
          onViewportChange={viewport => setViewport(viewport)}
          mapStyle="mapbox://styles/mapbox/dark-v9"
        >
          <Source type="geojson" data={data}>
            <Layer {...heatMapLayer} />
          </Source>
        </ReactMapGL>
      )}
    </div>
  )
}

export default App

Now this is completed, you can run:

yarn start

And you will see something like this:

'Screenshot of COVID tracker'

From here, you can easily add more context to the heatmap, further data such as US county data, or change the styling.

Conclusion

As you can see, it’s very easy to get up and running with react-map-gl and a basic dataset. There are so many excellent data sources, and being able to see them visually is a very powerful technique.

If you want a to see the full code, click here.

Made something cool by following this guide? Tweet me @ruairidhwm and let me know!

A tech newsletter that teaches you something new

This blog was created to document my own learning, and share useful tips with other software engineers.

My newsletter is like that, but straight to your inbox! It contains useful links I've found around the web, sneak peeks of my new articles, and access to free resources I've created.

Sign Up

You can unsubscribe whenever you'd like, and I probably hate spam even more than you do.