React + Mapbox GL JS: Custom Markers, Popups, and Bounds-Based Data Fetching
If you've added a Mapbox map in a React app before, you've probably hit a wall pretty quickly: Mapbox GL JS manages its own DOM, and React manages a virtual DOM, and getting the two to play nicely takes some thought. Markers and popups are a great example of this tension — they're imperative APIs in a world where you want declarative components.
This post walks through a pattern I've landed on for wrapping mapboxgl.Marker and mapboxgl.Popup in composable React components, and combining them with bounds-based data fetching to build something like a real estate or earthquake explorer map — where the data on the map updates as you pan and zoom.
Here's what we'll cover:
- Wrapping
mapboxgl.Markerin a React component that handles its own lifecycle - Using
createPortalto render custom marker content in React - Fetching data from an API using the map's current bounding box
- Tracking an active marker with React state
- Wrapping
mapboxgl.Popupin a component
If you want to see the finished product first, the source code is on GitHub.
The Core Challenge: Two DOMs
Before we get into the code, it's worth understanding why this requires a pattern at all. When you use React's normal component tree, React controls the DOM. But mapboxgl.Marker also creates and manages DOM nodes — it places an element on the map at a specific geographic coordinate, handles repositioning as you pan, and removes itself cleanly when you're done with it.
The approach here is to use React to manage the lifecycle of each marker (mount when data arrives, unmount when data goes away), while letting Mapbox handle the positioning. We use React's createPortal to render JSX content into a DOM node that we hand off to Mapbox.
The example discussed below is a map that fetches earthquake data for the current viewport, displaying a custom Marker and Popup for each. As you move the map and the bounds change, new data is fetched and the Markers and Popups are updated accordingly. Try it out:
Setting Up: A Map Component That Fetches Data
Start with a basic Mapbox GL JS map in a React component. The setup here is pretty standard — a useRef for the map instance, a useRef for the container element, and a useEffect to initialize the map on mount and clean it up on unmount.
// src/Map.jsx
import React, { useEffect, useRef, useState, useCallback } from 'react'
import mapboxgl from 'mapbox-gl'
import 'mapbox-gl/dist/mapbox-gl.css'
function Map() {
// mapRef holds the mapboxgl.Map instance so we can call methods on it
// without triggering re-renders when it changes
const mapRef = useRef()
// mapContainerRef points to the DOM element that Mapbox will render into
const mapContainerRef = useRef()
// earthquakeData holds the GeoJSON FeatureCollection returned by the API
const [earthquakeData, setEarthquakeData] = useState()
// useCallback prevents this function from being recreated on every render,
// which matters because we pass it to map.on() event listeners
const getBboxAndFetch = useCallback(async () => {
// getBounds() returns the lat/lng bounds of what's currently visible on screen
const bounds = mapRef.current.getBounds()
try {
const data = await fetch(
// Pass the SW and NE corners of the viewport as query params
// so the API only returns earthquakes within the current view
`https://earthquake.usgs.gov/fdsnws/event/1/query?format=geojson` +
`&starttime=2024-01-01&endtime=2024-01-30` +
`&minlatitude=${bounds._sw.lat}&maxlatitude=${bounds._ne.lat}` +
`&minlongitude=${bounds._sw.lng}&maxlongitude=${bounds._ne.lng}`
).then(d => d.json())
setEarthquakeData(data)
} catch (error) {
console.error(error)
}
}, [])
useEffect(() => {
mapRef.current = new mapboxgl.Map({
accessToken: 'YOUR_MAPBOX_ACCESS_TOKEN',
container: mapContainerRef.current,
center: [124, -1.98], // Start centered on Indonesia, an earthquake-prone region
minZoom: 5.5, // Prevent zooming out too far — keeps API responses manageable
zoom: 5.5
})
// Fetch once when the map finishes its initial load
mapRef.current.on('load', getBboxAndFetch)
// Fetch again whenever the user pans or zooms to a new area
mapRef.current.on('moveend', getBboxAndFetch)
// Clean up the map instance when this component unmounts
return () => mapRef.current.remove()
}, [])
return <div id='map-container' ref={mapContainerRef} />
}
The key piece here is getBboxAndFetch. It reads the map's current bounds using Map.getBounds(), constructs a URL to fetch earthquake data with those bounds as query parameters, and stores the response in state. It fires once on load and again on every moveend — so every time the user pans or zooms, new data comes in for all earthquakes that occurred in whatever part of the world is in view.
I'm wrapping it in useCallback to avoid it being recreated on every render, which would cause issues with the event listener setup.
The Marker Component
Here's where things get interesting. Rather than imperatively adding and removing markers in the Map component, we can delegate that work to a dedicated Marker component that handles its own lifecycle.
The trick is using createPortal to render JSX content into a DOM node that we create ourselves and hand to mapboxgl.Marker as its element.
// src/Marker.jsx
import { useEffect, useRef } from "react"
import mapboxgl from "mapbox-gl"
import { createPortal } from "react-dom"
const Marker = ({ map, feature, isActive, onClick }) => {
const { geometry, properties } = feature
// markerRef holds the mapboxgl.Marker instance so we can remove it on unmount
const markerRef = useRef(null)
// contentRef is a plain DOM div that we create once — this is the bridge between
// React's rendering and Mapbox's marker system. We hand it to Mapbox, and
// React renders into it via createPortal below.
const contentRef = useRef(document.createElement("div"))
useEffect(() => {
// Pass contentRef.current as the element for the marker.
// Mapbox will position this div on the map at the given coordinates.
markerRef.current = new mapboxgl.Marker(contentRef.current)
.setLngLat([geometry.coordinates[0], geometry.coordinates[1]])
.addTo(map)
// Remove the marker from the map when this component unmounts.
// This fires automatically when the parent re-renders with new data
// and this earthquake feature is no longer in the result set.
return () => markerRef.current.remove()
}, [])
// createPortal renders our JSX into contentRef.current — the same div
// that Mapbox is positioning on the map. React controls the content,
// Mapbox controls the placement.
return createPortal(
<div
onClick={() => onClick(feature)}
style={{
display: "inline-block",
padding: "2px 10px",
borderRadius: "50px",
// Flip colors when this marker is the active (clicked) one
backgroundColor: isActive ? "#333" : "#fff",
boxShadow: "0px 2px 4px rgba(0, 0, 0, 0.5)",
fontFamily: "Arial, sans-serif",
fontSize: "14px",
fontWeight: "bold",
color: isActive ? "#fff" : "#333",
}}
>
{/* Show the earthquake's magnitude as the marker label */}
{properties.mag}
</div>,
contentRef.current
)
}
export default Marker
Let's break down what's happening:
-
contentRefholds a plain DOMdivthat we create once and never touch directly - We pass that
divtonew mapboxgl.Marker(contentRef.current)— Mapbox takes ownership of positioning it on the map -
createPortalrenders our JSX into that samediv, so React controls the content while Mapbox controls the placement - The
useEffectcleanup removes the marker from the map when the component unmounts
The result is that you can build marker content using normal React patterns — JSX, props, conditional styles, event handlers — and it all "just works" inside Mapbox's marker system.
Rendering Markers from Data
Back in the Map component, rendering markers is now straightforward. Map over the earthquake features, render a Marker for each one, and use feature.id as the React key. That last part is important — when new data comes in after a pan, React will diff the keys and only create new Marker instances for earthquakes that weren't in the previous result set.
// In Map.jsx
// activeFeature holds the full GeoJSON Feature of the currently selected marker,
// or undefined if no marker has been clicked yet
const [activeFeature, setActiveFeature] = useState()
// Called by a Marker when it's clicked — stores the feature so we can
// highlight the active marker and position the popup
const handleMarkerClick = (feature) => {
setActiveFeature(feature)
}
return (
<>
{/* The div that Mapbox renders the map canvas into */}
<div id='map-container' ref={mapContainerRef} />
{/* Guard against mapRef.current being undefined on the first render,
then render one Marker component per earthquake feature */}
{mapRef.current && earthquakeData?.features?.map((feature) => (
<Marker
key={feature.id} // Stable ID lets React reuse existing markers on re-renders
map={mapRef.current} // The map instance, so Marker can call .addTo(map)
feature={feature}
isActive={activeFeature?.id === feature.id} // True only for the clicked marker
onClick={handleMarkerClick}
/>
))}
</>
)
activeFeature stores the full feature object of the currently clicked marker. Passing isActive={activeFeature?.id === feature.id} to each Marker lets them style themselves differently when selected — in our case, flipping to a dark background and light text.
The Popup Component
Popups follow a similar pattern to markers. One difference is that we only ever need one popup instance — it just moves around based on which marker is active.
// src/Popup.jsx
import { useEffect, useRef } from "react"
import { createPortal } from "react-dom"
import mapboxgl from 'mapbox-gl'
const Popup = ({ map, activeFeature }) => {
// popupRef holds the single mapboxgl.Popup instance for its whole lifetime
const popupRef = useRef()
// contentRef is the same portal trick as in Marker — a plain DOM div
// that React renders into, which we then pass to the Mapbox popup
const contentRef = useRef(document.createElement("div"))
// Runs once on mount: create the popup instance but don't add it to the map yet.
// We wait until activeFeature is set before positioning and showing it.
useEffect(() => {
if (!map) return
popupRef.current = new mapboxgl.Popup({
closeOnClick: false, // Keep the popup open when the user clicks elsewhere on the map
offset: 20 // Push the popup up slightly so it doesn't overlap the marker
})
// Remove the popup from the map when this component unmounts
return () => popupRef.current.remove()
}, [])
// Runs whenever activeFeature changes: reposition the popup and update its content
useEffect(() => {
if (!activeFeature) return
popupRef.current
.setLngLat(activeFeature.geometry.coordinates) // Move popup to the clicked marker's location
.setHTML(contentRef.current.outerHTML) // Serialize the React-rendered content to an HTML string
.addTo(map) // Add (or re-add) the popup to the map
}, [activeFeature])
// Render the popup content into contentRef via a portal.
// By the time the second useEffect above reads contentRef.current.outerHTML,
// React will have already rendered this JSX into the div.
return createPortal(
<div>
<table>
<tbody>
<tr>
<td><strong>Time</strong></td>
{/* Convert the Unix timestamp (ms) to a human-readable local time */}
<td>{new Date(activeFeature?.properties.time).toLocaleString()}</td>
</tr>
<tr>
<td><strong>Magnitude</strong></td>
<td>{activeFeature?.properties.mag}</td>
</tr>
<tr>
<td><strong>Place</strong></td>
<td>{activeFeature?.properties.place}</td>
</tr>
</tbody>
</table>
</div>,
contentRef.current
)
}
export default Popup
There are two useEffect hooks here with different jobs:
- The first runs once on mount and creates the
mapboxgl.Popupinstance (but doesn't add it to the map yet) - The second runs whenever
activeFeaturechanges, and repositions the popup + updates its content
One thing to note: setHTML takes the outerHTML of contentRef.current, not a reference to the element itself. This is because Mapbox's popup API works with HTML strings for its content. The createPortal has already rendered our JSX into that DOM node by the time this runs, so outerHTML gives us the rendered output as a string.
Putting It All Together
The final Map component renders the map container, maps over the earthquake data to produce Marker components, and includes a single Popup that tracks the active feature:
// In Map.jsx
return (
<>
{/* The map canvas container */}
<div id='map-container' ref={mapContainerRef} />
{/* One Marker per earthquake — guarded so we don't render before the map exists */}
{mapRef.current && earthquakeData?.features?.map((feature) => (
<Marker
key={feature.id}
map={mapRef.current}
feature={feature}
isActive={activeFeature?.id === feature.id}
onClick={handleMarkerClick}
/>
))}
{/* A single Popup instance that repositions itself when activeFeature changes */}
{mapRef.current && (
<Popup map={mapRef.current} activeFeature={activeFeature} />
)}
</>
)
The mapRef.current && guards are there because the map instance doesn't exist until after the first render — we don't want to pass undefined as the map prop.
Where to Take It From Here
This pattern is intentionally minimal. Some things you'd want to add in a real app:
- Loading state: Show a spinner or disable interaction while a fetch is in progress
- Error handling: Surface API errors in the UI instead of just logging them
- Clustering: Group nearby markers when zoomed out — Mapbox GL JS has good support for this if you switch to a GeoJSON source/layer approach
- Your own data: Swap out the USGS API for anything that returns GeoJSON and can be filtered by bounding box. This pattern works well for store locators, property listings, event maps, etc.
The full source code for the earthquake demo is on GitHub if you want to run it locally or use it as a starting point.
Top comments (0)