Skip to content

Click a card, move the map: three bridges from React to MapLibre

Adityo Guni Waluyo

React card clicks don't move an imperative map by themselves. Three small bridges: a ref registry, a synthetic click, and flyTo.

TL;DR

Maplibre markers only toggle popups via clicks that bubble from their element to the map, so force-calling popup.addTo is unreliable. The fix bridges the gap with a ref-based registry that stashes focus requests until map load fires. A synthetic bubbling click plus flyTo moves the camera, and a seq counter lets repeat clicks on the same card retrigger.

I clicked an office card on the homepage, expecting the map next to it to zoom to that location. The map just sat there. No animation, no popup. Only silence.

My first reaction: force the popup to appear. I called popup.addTo(map) directly, or messed with marker styles through the DOM. The result was inconsistent. Sometimes it appeared, sometimes it errored, sometimes the position had nothing to do with the marker I meant. I briefly thought this was a bug in the version I use.

After reading the maplibre-gl 4.7.1 source, it isn't a bug, it's the design [1]. The marker doesn't listen for click events on its own DOM element. It listens to map.on('click') at the map level, then checks whether originalEvent.target is that marker's element or something inside it [1]. There is no official API like marker.openPopup() in this version. The one path into the library's own popup toggle is a click that bubbles from the marker element up to the map [3].

Bridge one: a ref registry for the race condition

Problem one: the race. A click from a React card can arrive before the map finishes loading. The map's load event only fires once its internal status turns true inside the render loop [2]. Run flyTo before that and it vanishes without a trace.

The fix is two useRef hooks. One holds the registry of created markers, the other stores a focus request that arrived before the map was ready. When the load event fires, I check that stash; if a request is pending, applyFocus runs. React documents refs as the place for values that don't affect visual output [4]. Plain state here would trigger re-renders while the map is busy initializing.

Bridge two: a synthetic click plus flyTo

Inside applyFocus, the order goes like this. Start by dispatching a synthetic click to the marker element.

marker.getElement().dispatchEvent(new MouseEvent('click', { bubbles: true }));

Because the event is created with bubbles: true, it travels up to the map container. The map recognizes the marker element as the target, and its internal toggle logic runs without me rewriting popup positioning.

Next, move the camera.

mapRef.current?.flyTo({ center: marker.getLngLat(), zoom: 13, essential: true });

The essential: true flag isn't decoration. When a user enables reduced motion in their OS accessibility settings, map transitions become instant unless this option is set [2]. Last, scrollIntoView on the map container, so on a narrow screen the popup doesn't appear outside the viewport.

Bridge three: seq for repeated clicks

A classic React trap. The user clicks card A, the map focuses A. They click card A again, and the effect doesn't run, because the useEffect dependency is considered unchanged.

The fix is a seq counter. Every card click bumps it by one, and it goes into the dependency array. Repeated clicks on the same card still count as a valid change, and applyFocus runs again. This simple counter pattern is what saves the "compare two offices back and forth" interaction.

Final polish on the popup

Two small details close this story. The popup anchor is pinned to anchor: 'bottom'. The default is auto, which likes to flip the popup when it hits a screen edge. For office popups I want it always above the marker dot, consistent, not jumping around.

Second, the popup content got rebuilt: an icon badge, the office type label, an address block, then contact rows with icons. The popup ended up not just functional but pleasant to read when it appears after the animation.

The biggest lesson from this office-map.tsx component: not everything can go through React state. Sometimes an imperative library needs a small bridge that respects how it works, not how we wish it worked. Three bridges, and a card click finally moves the map.

Sources: [1] https://github.com/maplibre/maplibre-gl-js/blob/v4.7.1/src/ui/marker.ts [2] https://github.com/maplibre/maplibre-gl-js/blob/main/src/ui/map.ts [3] https://github.com/maplibre/maplibre-gl-js/blob/main/src/ui/marker.ts [4] https://github.com/reactjs/react.dev/blob/main/src/content/reference/react/useRef.md

Related articles