React Simple Maps

<-Docs
Working with data

Guides

Working with data

React Simple Maps prioritizes flexibility to enable you to work with any map files and data that you want to display on a map. While showing markers on a map bound to coordinates is a relatively straightforward task, choropleths present a bigger challenge, since you have to join the data from a csv/json file with data from your map file (TopoJSON/GeoJSON).

You can of course also use scripts and software to first put your data into the TopoJSON file so that you can access the data via geo.properties.dataPoint, but in this case the assumption is that you have a data file (e.g. csv) and a map file (TopoJSON) and you want to combine them to create a choropleth using React Simple Maps. The color scale in this example is a quantized scale from d3-scale.

Dependencies

npm install d3-scale d3-dsv

You don't have to use d3-scale or d3-dsv. If you have a csv parser you can just use that instead of d3-dsv, and if you are using something like chroma.js, you can use that to create a scale. This is just a starting point.

The data

This guide assumes each feature in your map file carries an id on its properties:

{
  "type": "Feature",
  "properties": { "id": "36047", "name": "Kings County, NY" },
  "geometry": { "type": "Polygon", "coordinates": [] }
}

Note that this property does not have to be called id, it can be named iso, name, or anything else, as long as you know what it is and what the values are.

Where that id lives varies by map file. TopoJSON also allows a top-level id on each geometry, in which case you can read geo.id instead of geo.properties.id — everything else below is the same. Open your map file and check which one it uses before writing the join. If you don't like inspecting TopoJSON files you can also just console log console.log(geo) before rendering a geography. This will show you what ids you have available.

For the purpose of this guide, you can download the counties TopoJSON file, and the sample unemployment data:

Put your CSV in a public/ folder so it is served as a static file:

id,name,year,rate
36047,"Kings County, NY",2025,5.4
36049,"Lewis County, NY",2025,4.3
36051,"Livingston County, NY",2025,3.8
36053,"Madison County, NY",2025,3.9

Here again, the headers are just a suggestion. You can name these whatever you like, but for the purpose of this example, we will keep id in the csv, and id in the map file. That way you know what has to match up. You can match any header against any property.

Loading the data

To load the data, we create a useUnemploymentData hook that runs useEffect under the hood and uses useState to save the loaded data. Note that the following example is demo-grade loading. For production, you would need to add a res.ok check, a .catch, and a cancellation flag so a late response cannot set state after unmount.

import { useEffect, useState } from "react"
import { csvParse } from "d3-dsv"
 
const geoUrl = "/us-counties-2025.json"
const dataUrl = "/unemployment-by-county-2025.csv"
 
function useUnemploymentData() {
  const [rates, setRates] = useState(new Map())
 
  useEffect(() => {
    fetch(dataUrl)
      .then((res) => res.text())
      .then((text) => {
        const rows = csvParse(text, (row) => ({
          id: row.id,
          rate: +row.rate,
        }))
        setRates(new Map(rows.map((d) => [d.id, d.rate])))
      })
  }, [])
 
  return rates
}

Make sure to cast row.rate as a number, CSV values are usually strings by default. In order to improve performance we are using Map, which is a bit faster than using .find() for each geography during render. Note that Map will not register duplicate keys (it will use the last entry), so make sure each id only appears once in your dataset.

Starting from an empty Map rather than null is deliberate: the map can render before the CSV arrives, with every geography displaying the "no data" fill. That is what lets both files load in parallel.

Letting both files load in parallel

Geographies fetches the TopoJSON itself when you pass it a URL, and that fetch starts when the component mounts. So do not gate the map behind the CSV:

// Don't: the TopoJSON fetch cannot start until the CSV has arrived
if (!rates) return <p>Loading…</p>
 
return <ComposableMap>{/* … */}</ComposableMap>

You can instead render the map before the data for the unemployment rates arrives:

// Do: both requests load at the same time and the map renders empty first
return (
  <ComposableMap projection="geoAlbersUsa">
    <Geographies geography={geoUrl}>{/* … */}</Geographies>
  </ComposableMap>
)

The color scale

scaleQuantize slices a continuous domain into equal-width buckets.

import { scaleQuantize } from "d3-scale"
 
const colorScale = scaleQuantize()
  .domain([0, 10])
  .range([
    "#ffedea",
    "#ffcec5",
    "#ffad9f",
    "#ff8a75",
    "#ff5533",
    "#e2492d",
    "#be3d26",
    "#9a311f",
    "#782618",
  ])

There are a number of ways to determine the domain. You can derive it from the dataset itself or hardcode it. In this case the domain is hardcoded. This prevents outliers from skewing the colors too much, and it also allows for the comparison of data from different years in the same map. If you had a dataset from 2018, or 2020, you could compare the visual output easily with a hardcoded scale.

scaleQuantize clamps, so a value above the domain gets the darkest color rather than undefined. This means that the fill of the outlier counties will correctly reflect the high unemployment rate there.

Joining the data to the map

The join happens inside the Geographies render prop. Each geography keeps its properties through from the source file, so a lookup per feature is all it takes:

import { ComposableMap, Geographies, Geography } from "react-simple-maps"
 
const noDataFill = "#f0f0f0"
 
export function UnemploymentMap() {
  const rates = useUnemploymentData()
 
  return (
    <ComposableMap projection="geoAlbersUsa" width={975} height={610}>
      <Geographies geography={GEO_URL}>
        {({ geographies }) =>
          geographies.map((geo) => {
            const rate = rates.get(geo.properties?.id)
            return (
              <Geography
                key={geo.rsmKey}
                geography={geo}
                fill={rate == null ? noDataFill : colorScale(rate)}
                stroke="#ffffff"
                strokeWidth={0.5}
              />
            )
          })
        }
      </Geographies>
    </ComposableMap>
  )
}

Note that geoAlbersUsa is a composite map projection. Alaska and Hawaii appear close to the contiguous US.

Note that in order to determine whether there is no data you cannot just do !rate, since that will also contain states with a rate of 0. This is why the comparison is rate == null.

Getting the join right

When a choropleth renders entirely grey, the join failed rather than the scale. Here are a number of reasons that are usually the cause of this:

The id is somewhere else

Always make sure to inspect the geo.properties of the map file you are using. Different map files store id or other identifiers (e.g. STATE_ABBR, iso_a2 etc.) differently. There is no convention for this, and TopoJSON also allows for id to be stored as geo.id, rather than geo.properties.id.

You can check one geography before writing the join.

<Geographies geography="...">
  {({ geographies }) => {
    console.log(geographies[0])
    return geographies.map((geo) => (
      <Geography key={geo.rsmKey} geography={geo} />
    ))
  }}
</Geographies>

Or you can just check all geographies:

<Geographies geography="...">
  {({ geographies }) =>
    geographies.map((geo) => {
      console.log(geo) // OR console.log(geo.properties)
      return <Geography key={geo.rsmKey} geography={geo} />
    })
  }
</Geographies>

String versus number

new Map([[1, 5.3]]).get("1") is undefined. A Map keyed by number will not match a string id, and CSV parsing gives you strings. Pick one type on both sides — string is the safer default — and coerce explicitly if either side might vary:

const rate = rates.get(String(geo.properties.id))

Case and whitespace

"us-ca" does not match "US-CA", and a trailing space from a hand-edited CSV is invisible in a diff. Normalise both sides on load if the ids come from different sources:

id: row.id.trim().toUpperCase()

Checking the join before debugging the colors

A count can quickly tell you how many geographies are being matched:

<Geographies geography="...">
  {({ geographies }) => {
    const matched = geographies.filter((geo) =>
      rates.has(geo.properties.id)
    ).length
    console.log(`${matched} / ${geographies.length} matched`)
    return geographies.map((geo) => (
      <Geography key={geo.rsmKey} geography={geo} />
    ))
  }}
</Geographies>

0 / 51 is a key mismatch — compare one id from each side directly. 51 / 51 with a grey map means the scale or the fill logic is at fault, not the join.

Run this after the CSV has loaded. Note that on the first render the count is 0 / 51, because the lookup is still empty.

Using parseGeographies for better performance

The lookup above runs on every render. To do the join once, when the geographies are prepared, attach the values with parseGeographies:

const noDataFill = "#f0f0f0"
 
const attachRates = useCallback(
  (features) =>
    features.map((feature) => ({
      ...feature,
      properties: {
        ...feature.properties,
        rate: rates.get(feature.properties?.id) ?? null,
      },
    })),
  [rates]
)
 
<Geographies geography="..." parseGeographies={attachRates}>
  {({ geographies }) =>
    geographies.map((geo) => (
      <Geography
        key={geo.rsmKey}
        geography={geo}
        fill={
          geo.properties.rate == null
            ? noDataFill
            : colorScale(geo.properties.rate)
        }
      />
    ))
  }
</Geographies>

parseGeographies is compared by reference, so it must be wrapped in useCallback — a fresh function each render re-runs the whole data preparation. Note the dependency on rates: the join re-runs once when the CSV arrives, which is exactly what you want.

For a map that renders one dataset, the inline lookup is simpler and fast enough. Reach for parseGeographies when the joined value should be available everywhere that touches the feature — tooltips, sorting, a details panel — instead of recomputed at each call site.

Interaction and hover states

Setting fill as a prop will result in a static color that cannot respond to hover or focus. You can however hand the data-driven color to CSS as a variable, and let the stylesheet define the interactive behavior:

<Geography
  key={geo.rsmKey}
  geography={geo}
  style={{
    "--fill": rate == null ? "#f0f0f0" : colorScale(rate),
    "--fill-hover": rate == null ? "#f0f0f0" : hoverColor(colorScale(rate)),
  }}
/>
.rsm-geography {
  fill: var(--fill, #f0f0f0);
  outline: none;
  transition: fill 100ms ease;
}
.rsm-geography:hover {
  fill: var(--fill-hover, #0066ff);
}

The styling guide covers this in depth, including deriving a hover color that fits each generated fill.

For a tooltip, keep the value in React rather than in CSS:

<Geography
  key={geo.rsmKey}
  geography={geo}
  onMouseEnter={() =>
    setTooltip({
      name: geo.properties.name,
      rate: rate == null ? "No data" : `${rate.toFixed(1)}%`,
    })
  }
  onMouseLeave={() => setTooltip(null)}
/>

TypeScript

GeoJSON types properties as GeoJsonProperties. The type of geo.properties.id is any and TypeScript will not catch a typo in the field name. Declare the shape you expect:

type StateProps = { id: string; name: string }
 
const rates = new Map<string, number>()
 
const rate = rates.get((geo.properties as StateProps).id)

For the CSV side, csvParse's row accessor hands you string | undefined per column:

type Row = { id: string; rate: number }
 
const rows = csvParse(
  text,
  (row): Row => ({
    id: row.id!,
    rate: +row.rate!,
  })
)

If your map file uses a top-level id instead, note that GeoJSON types it as string | number | undefined, so String(geo.id) is required before using it as a Map key.