Search code examples
javascriptreactjsonclicklistenerreact-leafletleaflet.markercluster

Render React component as a custom marker icon in react-leaflet with working events/interaction


    const IconGen = ({ incomingData }) => {
     
      const [dataMap, setDataMap] = useState({});
      const dataMapTemp = {}; 
      incomingData.BandData.forEach((each) => {
        dataMapTemp[each.AntennaX].list.push(each);
        dataMapTemp[each.AntennaX].angle= each.angle;
        dataMapTemp[each.AntennaX].sId = each.SID;
          });
      if (!dataMap[1]) {
 
        setDataMap(dataMapTemp);
       
      }

      return (
         <div class="container_Sector"  >
             <div class="circle" style={{backgroundColor : "#c31605"></div> 
                 <div 
              id = {dataMap && dataMap[1]?.sId } 
              
              style={{
                rotate: `${dataMap[1]?.angle}deg`,
              }}
              onClick={() => {
                alert("You clicked the sector!");
              }
              }
            >
              {dataMap[1]?.list.map((each) => generateSvg(each))}
            </div>
                 
            <div
              style={{
                rotate: `${dataMap[2]?.angle}deg`,
              }}
            >
              {dataMap[2]?.list.map((each) => generateSvg(each))}
            </div>
            <div 
              style={{
                rotate: `${dataMap[3]?.angle}deg`,
              }}
            >
              {dataMap[3]?.list.map((each) => generateSvg(each))}
            </div>
          </div>
        
      );
    };
    export default IconGen;

    //Parent Component

    <MapContainer>
       <Marker
               key={data.SiteID}
               position={[data.Latitude, data.Longitude]}
               icon = <IconGen
                incomingData={data}
     
              />
               
             >
     
             </Marker>
    </Mapcontainer>

I am able to render custom icon using icon={L.divIcon({ className: "custom icon", html: ReactDOMServer.renderToString( <MyComponent/> ) })}.

However the onClick within the custom icon component does not trigger. onClick is not working due to rendering the MyComponent using ReactDOMServer.renderToString.

I need the onClick event inside the custom component to function correctly.


Solution

  • I have now published this solution as a library at @adamscybot/react-leaflet-component-marker.

    The reason that the onClick handler does not work is that renderToString means the component isn't truly mounted, in the sense that React is not aware of it as an ongoing concern. Running renderToString reduces the component to whatever the DOM looks like for that component on its first render, and nothing else will change that from the point renderToString is called.

    The base problem here is that the react-leaflet library doesn't support this out of the box. However, we could get around this by:

    1. Using L.divIcon to render a dummy div as the icon containing nothing. We will assign a unique ID for that div for each marker, which will come in handy later.
    2. Using the Marker components add event to detect when the icon has actually been rendered to the DOM.
    3. When this happens, render the real React component inside the dummy marker by using a React portal that targets the div with the aforementioned unique ID.
    4. Additionally detect when the marker is removed and remove the portal.

    We can encapsulate this behaviour in an EnhancedMarker component, for ease of use.

    Here is a working CodeSandbox of the proof of concept. In this proof of concept, I am rendering two buttons as a marker, each with click events that work.

    The below includes the generic code that can be applied to any situation:

    import React, { useState, useId, useMemo } from "react";
    import { createPortal } from "react-dom";
    import { MapContainer, TileLayer, Marker } from "react-leaflet";
    
    // `EnhancedMarker` has the same API as `Marker`, apart from the `icon` can be a React component.
    const EnhancedMarker = ({
      eventHandlers,
      icon: providedIcon,
      ...otherProps
    }) => {
      const [markerRendered, setMarkerRendered] = useState(false);
      const id = "marker-" + useId();
    
      const icon = useMemo(
        () =>
          L.divIcon({
            html: `<div id="${id}"></div>`,
          }),
        [id]
      );
    
      return (
        <>
          <Marker
            {...otherProps}
            eventHandlers={{
              ...eventHandlers,
              add: (...args) => {
                setMarkerRendered(true);
                if (eventHandlers?.add) eventHandlers.add(...args);
              },
              remove: (...args) => {
                setMarkerRendered(false);
                if (eventHandlers?.remove) eventHandlers.remove(...args);
              },
            }}
            icon={icon}
          />
          {markerRendered &&
            createPortal(providedIcon, document.getElementById(id))}
        </>
      );
    };
    
    const MarkerIconExample = () => {
      return (
        <>
          <button onClick={() => console.log("button 1 clicked")}>Button 1</button>
          <button onClick={() => console.log("button 2 clicked")}>Button 2</button>
        </>
      );
    };
    
    const CENTER = [51.505, -0.091];
    const ZOOM = 13;
    const App = () => {
      return (
        <MapContainer center={CENTER} zoom={ZOOM}>
          <TileLayer
            attribution='&amp;copy <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
            url="https://{s}.tile.osm.org/{z}/{x}/{y}.png"
          />
          <EnhancedMarker position={CENTER} icon={<MarkerIconExample />} />
        </MapContainer>
      );
    };
    
    
    

    For your example, you should be able to:

    1. Copy in the new EnhancedMarker component.
    2. Change existing usages of <Marker> in your use case to <EnhancedMarker>.
    3. Simply use <IconGen /> in the <EnhancedMarker> icon prop.