Skip to content
Advanced mapping with Gatsby and React Leaflet
Photo by Henry Perks on Unsplash
  • Gatsby

Advanced mapping with Gatsby and React Leaflet

January 26, 2021 8 Min

Hang on to your hats - this is a blustery one! In this post we walk through creating an imaginary real estate mapping app using Gatsby and React Leaflet.

We are using v3.0 of react-leaflet which was released at the end of 2020 - this code won’t work for v2 but I have working code for v2 of react-leaflet so feel free to ask me questions.

You can browse the finished code on GitHub and checkout a live demo of what we will be building. If you want to see a production version of this code you can poke around www.mindmapbc.ca/map.

A couple of notes before we get going:

  • I assume you have a basic familiarity with Gatsby and React hooks - but do my best to walk you through things. There just isn’t space here to explain what hooks like useState or useEffect do.

  • I focus almost entirely on the javascript side of things in this post so what we build ain’t pretty!

  • Most of the code in this post is just plain React and would be transferable with to other React based frameworks, e.g. NextJs

  • If you are already familiar with react-leaflet you can likely skip straight to part 2 where we begin covering the more advanced mapping topics.

Part 1: Initial map setup

We are going to setup a quick starter project, add our dependencies, display a basic map and then add some imaginary placeholder data. In a real world application your geolocation data would be coming from an API/backend of some kind.

Step 1: Installation and dependencies

Here we are using Gatsby Theme Catalyst (but you can use any starter you want) to quickly bootstrap a working Gatsby project.

## Bootstrap a quick starter
gatsby new advanced-mapping https://github.com/ehowey/gatsby-starter-catalyst
cd advanced-mapping
## Add our dependencies
yarn add gatsby-plugin-react-leaflet react-leaflet leaflet

Next you need to add gatsby-plugin-react-leaflet to your Gatsby plugins array.

// gatsby-config.js
plugins: [
// other plugins...
`gatsby-plugin-react-leaflet`,
]

Step 2: Adding our map

Now let’s add a very basic map to our project. Create a new page at src/pages/map.js and copy paste the following code.

// src/pages/map.js
import React from "react"
import { MapContainer, TileLayer, Marker, Popup } from "react-leaflet"
import { useHasMounted } from "gatsby-theme-catalyst-core"
const Map = () => {
return (
<div>
{useHasMounted && (
<MapContainer
center={[51.505, -0.09]}
zoom={13}
style={{ height: "400px" }}
>
<TileLayer
attribution='&copy; <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<Marker position={[51.505, -0.09]}>
<Popup>
A pretty CSS3 popup. <br /> Easily customizable.
</Popup>
</Marker>
</MapContainer>
)}
</div>
)
}
export default Map

Save and refresh and you should be looking at a simple map on your page!

There are two gotchas worth noting. The first is that because Gatsby is statically rendered at build time you need to check that the component has mounted before rendering the map to the DOM. This is what our useHasMounted hook does for us. The second gotcha is that the map needs to have a height, without the height you will be staring at a blank screen.

Step 3: Create some placeholder data

Now let’s give ourselves a little bit of placeholder data to work with. The GeoJson format is nice to work with and is a becoming standardized across different mapping contexts making it easier to move data around if you need to. It generally follows the shape below where the geometry object stores information about geolocation and the properties object stores metadata about the location.

{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [125.6, 10.1]
},
"properties": {
"name": "Cottage home",
"price": 249999,
"bedrooms": 2,
"bathrooms": 1
}
}

Copy and paste this placeholder data into your Gatsby app at src/data/geojson.json.

Step 4: Adding GeoJson data to the map

To add our imaginary real estate data to the map we will use the GeoJson component from react-leaflet which we will import and use to replace the Marker and Popup components from our initial map.

Here is what your map.js file should look like now.

// src/pages/map.js
import React from "react"
import { MapContainer, TileLayer, GeoJSON } from "react-leaflet"
import { useHasMounted } from "gatsby-theme-catalyst-core"
import geojson from "../data/geojson.json"
const Map = () => {
return (
<div>
{useHasMounted && (
<MapContainer
center={[51.072806, -114.11918]}
zoom={10}
style={{ height: "400px" }}
>
<TileLayer
attribution='&copy; <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<GeoJSON data={geojson} />
</MapContainer>
)}
</div>
)
}
export default Map

Save and refresh your browser, you should see our new markers on the map. The GeoJson component automatically creates the markers for us - now we are getting somewhere!

There are two gotchas worth noting here with GeoJson in react-leaflet. The standard coordinate format in GeoJson and react-leaflet is different; for GeoJson coordinates are stored as [lng, lat] whereas react-leaflet uses coordinates in the [lat, lng] format. This has tripped me up more than a few times! The second gotcha we cover below and has to do with dynamic data.

Now that we have a basic map we can start building the more advanced functionality our real estate app needs.

Map tiles

Just a quick word about map tiles. In the demo code you will notice that I am using OpenStreetMap tiles. This is fine for testing or development but in production you will need to use, and pay, for a map tile service. I reviewed a few different options and ended up going with MapBox for my projects but there are lots of different options to choose from.

Part 2: Advanced mapping patterns

The core purpose of mapping applications is to visualize geographical data that would otherwise be too difficult to understand. Mapping applications have become ubiquitous in our lives whether it is for driving directions, restaurant reviews, or booking a hotel room. In order to help users find what they are looking for most mapping applications implement a robust searching and filtering UI. This is where the code complexity starts to ramp up.

In this section I am going to talk about clustering map markers, implementing custom marker popups, dynamic map data (e.g. data changes after filtering), dynamically showing only the results that are visible in the viewport, and finally adding a “locate me” button.

This code is for react-leaflet v3.0 which was a fairly major change from v2.x.x and involves a switch to a hooks based API.

Where the logic happens

I originally wrote the majority of this code for v2.x.x of react-leaflet and had to update it to work with v3.0. The biggest change in my experience was how you access the leaflet map element, and its child objects like layers and panes. In 2.x.x you can create a ref with useRef to the map component and then use this ref to access the leaflet map element. In v3.0 accessing the map element is simplified in a react hook, however this also means that you need to be inside the MapContainer component to use the hook.

We are going to add our logic to separate functional components, e.g. GetVisibleMarkers and to the map page itself. Here is an outline of what that final page structure will look like once we complete all the steps below. You might find it helpful to reference the code on GitHub and this post simultaneously to see the code snippets in context of the larger project.

// src/pages/map.js
import React, { useState, useEffect, useRef } from "react"
import AddLocate from "../lib/add-locate"
import GetVisibleMarkers from "../lib/get-visible-markers"
import UpdateMapPosition from "../lib/update-map-position"
//... more imports
const Map = () => {
// REFS
//...groupRef and clusterRef
// STATE
//...geoJsonKey, displayedMarkers, visibleMarkers
// FUNCTIONS
//...createPopups, createClusters, useEffect
// Some other stuff that is not relevant
return (
<>
{/*more above here*/}
{useHasMounted && (
<MapContainer
center={[51.072806, -114.11918]}
zoom={10}
style={{ height: "400px" }}
>
<TileLayer
attribution='&copy; <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<FeatureGroup ref={groupRef} name="Homes">
<MarkerClusterGroup ref={clusterRef}>
<GeoJSON
data={displayedMarkers}
key={geoJsonKey}
onEachFeature={createPopups}
/>
</MarkerClusterGroup>
</FeatureGroup>
<UpdateMapPosition
geoJsonKey={geoJsonKey}
groupRef={groupRef}
displayedMarkers={displayedMarkers}
/>
<GetVisibleMarkers
geoJsonKey={geoJsonKey}
groupRef={groupRef}
clusterRef={clusterRef}
setVisibleMarkers={setVisibleMarkers}
/>
<AddLocate />
</MapContainer>
)}
{/*more below here*/}
</>
)
}
export default Map

Clustering your map markers

As our real estate app gains more data points a common practice is to visually group or cluster nearby map markers together for a better user experience.

The first thing we will do is install react-leaflet-markercluster. We are working with v3.x of react-leaflet so you need to make sure that you install at least v3.x of react-leaflet-markercluster.

## You may be able to remove the @next tag
## Make sure you have at least v3.0
yarn add leaflet-markercluster react-leaflet-markercluster@next

We are also going to add the FeatureGroup component at this same time, as we will need it later on. Both the cluster component and the feature group component will need refs as we will need to track data from these components later on. At this stage we are also going to import Leaflet itself since we will be doing some customizing of the markers Leaflet creates. Finally we are importing a little bit of CSS as well, more on that below.

// src/pages/map.js
import React, { useRef } from "react"
import { MapContainer, TileLayer, GeoJSON, FeatureGroup } from "react-leaflet"
import L from "leaflet"
import MarkerClusterGroup from "react-leaflet-markercluster"
import "react-leaflet-markercluster/dist/styles.min.css"
import "../lib/map.css"
//...
const groupRef = useRef()
const clusterRef = useRef()
//...more above here
<MapContainer
center={[51.072806, -114.11918]}
zoom={10}
style={{ height: "400px" }}
>
<TileLayer
attribution='&copy; <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<FeatureGroup ref={groupRef} name="Homes">
<MarkerClusterGroup
ref={clusterRef}
iconCreateFunction={createClusters}
>
<GeoJSON
data={geojson}
/>
</MarkerClusterGroup>
</FeatureGroup>
</MapContainer>

Next we want to control the appearance and colors for these clusters - the default colors do not have enough contrast in my opinion. To do this we need a custom icon creation function. Here is what that can look like giving you the ability to style the clusters in different colors based on how many items are inside of each cluster.

// src/pages/map.js
// Creating cluster icons for the map
// Copy and paste before your return inside Map component
const createClusters = (cluster) => {
const childCount = cluster.getChildCount()
let size = ""
if (childCount < 10) {
size = "small"
} else if (childCount < 25) {
size = "medium"
} else {
size = "large"
}
return L.divIcon({
html: `<div><span><b>${childCount}</b></span></div>`,
className: `custom-marker-cluster custom-marker-cluster-${size}`,
iconSize: new L.point(40, 40),
})
}

Now create a custom css file for your map at src/lib/map.css and copy and paste the css code from GitHub to your new file. You will notice there is some custom code in there for popups as well - don’t fear that is coming up next!

Now you should see your markers cluster together beautifully and those clusters dynamically update in size as you zoom the map in and out.

Adding custom popups to your markers

Next lets add some custom popups to our GeoJson markers. These popups or tooltips are used to display some basic information about a map marker - in the case of our real estate application you might want to show the home price, number of bedrooms, etc.

The GeoJson component has a prop called onEachFeature which we can use to specify a function for creating a popup on each feature or marker. In this example the popup is very simple but you can use CSS to style the popup and make it look as fancy as you want. While I don’t cover it here you can extend this concept to also customize the visual appearance of the marker itself.

// src/pages/map.js
// Creating popups for the map
// Copy and paste before your return inside Map component
const createPopups = (feature = {}, layer) => {
const { properties = {} } = feature
const { address, price, bedrooms, bathrooms } = properties
const popup = L.popup()
const html = `
<div class="popup-container">
<h3 class="popup-header">${address.street}</h3>
<ul>
<li><strong>Price:</strong> ${price.toString()}</li>
<li><strong>Bedrooms:</strong> ${bedrooms.toString()}</li>
<li><strong>Bathrooms:</strong>${bathrooms.toString()}</li>
</div>
`
popup.setContent(html)
layer.bindPopup(popup)
}

Next we need to assign the createPopups function to the onEachFeature prop for the GeoJson component.

// Run the popup function on each GeoJson feature
<GeoJSON data={geojson} onEachFeature={createPopups} />

Now you should see some plain popups appearing for each marker on the map. You can style and customize these popups with css and html, and at this point the sky is the limit really. If you were following along you should already have these styles in your src/lib/map.css file and that file should already be imported as well.

Voila you have some custom popups for your map markers!

Dynamic map data

Almost all mapping applications involve dynamic data - data that changes in real time due to user input (e.g. searching or filtering results). In our imaginary real estate app users might want to filter the results to show homes within a specific price range, homes only with a certain number of bedrooms, etc.

In the demo I created some very rudimentary buttons to simulate this, but in a production application you could handle this in a number of different ways depending on your needs. I have used FuseJS successfully for this in previous projects.

Now, traditionally in React you would just update state and then the appropriate components would rerender with the new data. However leafletjs and by extension react-leaflet prevent rerenders by default meaning we need to do a little bit of trickery behind the scenes to force a rerender with our filtered data.

Here is how this can be achieved.

// src/pages/map.js
// Track which markers are being actively displayed on the map
const [displayedMarkers, setDisplayedMarkers] = useState(geojson)
// GeoJson Key to handle updating geojson inside react-leaflet
const [geoJsonKey, setGeoJsonKey] = useState("initialKey123abc")
// Generate a new key to force an update to GeoJson Layer
useEffect(() => {
const newKey = makeKey(10)
setGeoJsonKey(newKey)
}, [displayedMarkers])
// Updated GeoJson component
<GeoJSON data={displayedMarkers} key={geoJsonKey} />
// Filter for three bedrooms only
const handleFilter = () => {
if (displayedMarkers.length > 0) {
const filteredMarkers = displayedMarkers.filter(
(marker) => marker.properties.bedrooms === 3
)
setDisplayedMarkers(filteredMarkers)
}
}

A bit of an explanation of what is happening here. We initialize two states; one to hold our GeoJson data, and one to hold a random string value for the key prop in React. In React when the key prop changes it forces a rerender. Lastly we have a useEffect hook that dynamically sets a new key value any time the displayed markers are updated.

In the demo you can test this out with the buttons I built to simulate filtering the data on the map. We have a dynamically updating map as the geojson data changes! Try commenting out the useEffect function in your code to see what happens without it.

Dynamically updating the map viewport

As your data changes you also want the map to dynamically adjust the viewport (zoom and center) to include the newly filtered data, this is especially important in larger mapping applications. This saves someone the trouble of having to pan or scroll the map to see their new search results.

Because of react-leaflet’s refactor to hooks we can handle this logic in its own discrete component.

// src/lib/update-map-position.js
import { useEffect } from "react"
import { useMap } from "react-leaflet"
const UpdateMapViewportLogic = ({ geoJsonKey, groupRef, displayedMarkers }) => {
// Access the map context with the useMap hook
const map = useMap()
// Reset the bounds of the map based on the displayed markers
const updateMapPosition = () => {
if (displayedMarkers.length > 0 && map && groupRef.current) {
const layer = groupRef.current
if (layer) {
map.fitBounds(layer.getBounds().pad(0.5))
}
}
}
// useEffect Hook to reset viewport when geoJson changes
useEffect(() => {
updateMapPosition()
}, [geoJsonKey]) //eslint-disable-line
return null
}
export default UpdateMapViewportLogic

Next we need to add this component below the other components inside the MapContainer. Here is what the component tree should now look like inside of map.js.

// src/pages.map.js
// Import it
import UpdateMapPosition from "../lib/update-map-position"
const Map = () => {
//...other stuff here
return (
//...other stuff above
<MapContainer
center={[51.072806, -114.11918]}
zoom={10}
style={{ height: "400px" }}
>
<TileLayer
attribution='&copy; <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<FeatureGroup ref={groupRef} name="Homes">
<MarkerClusterGroup
ref={clusterRef}
iconCreateFunction={createClusters}
>
<GeoJSON
data={displayedMarkers}
key={geoJsonKey}
onEachFeature={createPopups}
/>
</MarkerClusterGroup>
</FeatureGroup>
<UpdateMapPosition
geoJsonKey={geoJsonKey}
groupRef={groupRef}
displayedMarkers={displayedMarkers}
/>
</MapContainer>
//...other stuff below
)
}

Tracking the visible markers

Most mapping applications display a list of search results beside or below the map. It is common to also have these results dynamically update as the user pans or zooms the map. Effectively the map itself becomes its own filtering tool for the user as they search. To accomplish this we need to add some more state to our imaginary real estate application and then update that state as the map pans or zooms. Again hooks allow us to extract this logic into its own component.

// src/lib/update-map-position.js
import { useMap, useMapEvents } from "react-leaflet"
import L from "leaflet"
const GetVisibleMarkersLogic = ({
groupRef,
clusterRef,
setVisibleMarkers,
}) => {
// Access the map context with the useMap hook
const map = useMap()
// Get visible markers on the map
const getVisibleMarkers = () => {
if (map && groupRef.current && clusterRef.current) {
const cluster = clusterRef.current
let features = []
cluster.eachLayer(function (layer) {
if (layer instanceof L.Marker) {
if (map.getBounds().contains(layer.getLatLng())) {
features.push(layer.feature)
}
}
})
setVisibleMarkers(features)
}
}
// Hook to access map events from Leaflet API
useMapEvents({
zoomend: () => getVisibleMarkers(),
moveend: () => getVisibleMarkers(),
})
return null
}
export default GetVisibleMarkersLogic

Now we need to add this component to our main map page and initiate the state for our visible markers.

// src/pages/map.js
// Import it
import GetVisibleMarkers from "../lib/get-visible-markers"
const Map = () => {
//...other stuff here
// Add the state
const [visibleMarkers, setVisibleMarkers] = useState(geojson)
return (
//...other stuff above
<MapContainer
center={[51.072806, -114.11918]}
zoom={10}
style={{ height: "400px" }}
>
<TileLayer
attribution='&copy; <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<FeatureGroup ref={groupRef} name="Homes">
<MarkerClusterGroup
ref={clusterRef}
iconCreateFunction={createClusters}
>
<GeoJSON
data={displayedMarkers}
key={geoJsonKey}
onEachFeature={createPopups}
/>
</MarkerClusterGroup>
</FeatureGroup>
<UpdateMapPosition
geoJsonKey={geoJsonKey}
groupRef={groupRef}
displayedMarkers={displayedMarkers}
/>
<GetVisibleMarkers
geoJsonKey={geoJsonKey}
groupRef={groupRef}
clusterRef={clusterRef}
setVisibleMarkers={setVisibleMarkers}
/>
</MapContainer>
//...other stuff below
)
}

Now that you have the visible markers being tracked in state you can display those visible markers inside of your application just as you would any other kind of data. Here is a very rough version of how I did it in the demo code.

<div>
<h2>Visible Markers</h2>
<div style={{ display: "flex", flexWrap: "wrap" }}>
{visibleMarkers.map((marker) => (
<div key={marker.properties.address.street} style={{ padding: "1rem" }}>
<h3>{marker.properties.address.street}</h3>
<ul>
<li>Price: {marker.properties.price.toString()}</li>
<li>Bedrooms: {marker.properties.bedrooms.toString()}</li>
<li>Bathrooms: {marker.properties.bathrooms.toString()}</li>
</ul>
</div>
))}
</div>
</div>

Adding a locate button

Most mapping applications have some kind of geolocation functionality built in - here we are going to make that user controllable through a “Locate me” button on the map. We will rely on the leaflet-locatecontrol package to handle most of the heavy lifting for us.

First we need to install leaflet-locatecontrol.

yarn add leaflet.locatecontrol

Next we need to add the necessary Font Awesome css to our html head element. FYI, this is the recommended installation process, but is not performant. You can override the css to include your own custom icons negating the need for the Font Awesome library.

// src/pages/map.js
import { Helmet } from "react-helmet"
const Map = () => {
//...other code is here
return (
<>
<Helmet>
<link
rel="stylesheet"
href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css"
/>
</Helmet>
{/* Other code is below here */}
</>
)
}

Now we need to handle actually adding the locate button to the map which we will do with another functional component.

// src/lib/add-locate.js
import { useEffect } from "react"
import { useMap } from "react-leaflet"
import Locate from "leaflet.locatecontrol"
import "leaflet.locatecontrol/dist/L.Control.Locate.min.css"
const AddLocateLogic = () => {
// Access the map context with the useMap hook
const map = useMap()
// Add locate control once the map loads
useEffect(() => {
const locateOptions = {
position: "bottomright",
// Set other options in here for locate control
// flyTo: true,
// drawCircle: false,
// showPopup: false,
}
const locateControl = new Locate(locateOptions)
locateControl.addTo(map)
}, [map])
return null
}
export default AddLocateLogic

Finally we need to add this component to our main map.js page.

// src/pages/map.js
// Import it
import AddLocate from "../lib/add-locate"
const Map = () => {
//...other stuff here
// Add the state
const [visibleMarkers, setVisibleMarkers] = useState(geojson)
return (
//...other stuff above
<MapContainer
center={[51.072806, -114.11918]}
zoom={10}
style={{ height: "400px" }}
>
<TileLayer
attribution='&copy; <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<FeatureGroup ref={groupRef} name="Homes">
<MarkerClusterGroup
ref={clusterRef}
iconCreateFunction={createClusters}
>
<GeoJSON
data={displayedMarkers}
key={geoJsonKey}
onEachFeature={createPopups}
/>
</MarkerClusterGroup>
</FeatureGroup>
<UpdateMapPosition
geoJsonKey={geoJsonKey}
groupRef={groupRef}
displayedMarkers={displayedMarkers}
/>
<GetVisibleMarkers
geoJsonKey={geoJsonKey}
groupRef={groupRef}
clusterRef={clusterRef}
setVisibleMarkers={setVisibleMarkers}
/>
<AddLocate />
</MapContainer>
//...other stuff below
)
}

Wrap up

Woowee! You made it. Congratulations there was lots happening there and I hope you could follow along. By now you should have created a pretty full featured (but unattractive) mapping application. I hope that you found this useful and I would love to hear any feedback or suggestions you have.

If you are looking for more reading Colby Fayock has done a lot of writing on mapping in React. He also has a great course on mapping with Egghead.io as well which I found really helpful.

Happy coding!


← Previous Post

Illustrations by Diana Valeanu
© 2021 Eric Howey - eric@erichowey.dev

In the spirit of reconciliation, I acknowledge that I live, work and play on the traditional territories of the Blackfoot Confederacy (Siksika, Kainai, Piikani), the Tsuut’ina, the Îyâxe Nakoda Nations, the Métis Nation (Region 3), and all people who make their homes in the Treaty 7 region of Southern Alberta.