jahed.dev

React Portals and Leaflet

Leaflet is an excellent library for creating interactive maps. It's one of the core technologies I'm using in FrontierNav, and one of the most mature browser-based mapping libraries out there. FrontierNav eventually moved over to React, so ideally I'd want a mapping library that also used React. Sadly nothing's really as mature as Leaflet, so I stuck with it.

React and Leaflet Interoperability

One of the issues I came across after moving the project over, was trying to integrate React's update logic with Leaflet's custom markers, dubbed "DivIcons". For a good year, I left the logic intact, using custom logic to manually manipulate DOM elements by subscribing to a Redux store. It worked but it was complicated and a pain to revisit.

Eventually I moved over to react-leaflet, a library which wraps Leaflet and provides pre-made React Components to represent various Leaflet components (Maps, Tile Layers, Markers, etc.). This was a huge improvement. While it was still using Leaflet's API under the hood, the update logic and state diffing could be handled in React like every other component in the app. Perfect!

Except, there was one major issue. react-leaflet does not support React-powered DivIcons and it doesn't plan to. The aim of react-leaflet is to be a thin layer on top of Leaflet's API without any additional features. So, what can I do?

Solution: Multiple React Trees

Since react-leaflet uses a simple inheritance-based API to create its wrappers, we can hook into that same functionality to create our React-powered DivIcons.

  1. Extend a MapLayer, which can be added to a Map. Let's call this "CustomMarker"
  2. Wait for layer to be mounted.
  3. Create a Marker with an empty DivIcon. This Marker is what our "CustomMarker" wraps.
  4. Once it's mounted, render its children into the DivIcon's DOM element using ReactDOM.render.

With this approach, we're essentially creating multiple React trees. A new one for each marker. It's not optimal but it works! Multiple React trees can't easily talk to each other, it's like having two separate apps. There's also some workarounds to maintain context between the multiple trees (e.g. for Redux). You can see this here:

function createContextProvider(context) {
  class ContextProvider extends Component {
    getChildContext() {
      return context;
    }

    render() {
      return this.props.children;
    }
  }

  ContextProvider.childContextTypes = {};
  Object.keys(context).forEach((key) => {
    ContextProvider.childContextTypes[key] = PropTypes.any;
  });
  return ContextProvider;
}

class CustomMarker extends MapLayer {
  // ...

  renderComponent = () => {
    const ContextProvider = createContextProvider({
      ...this.context,
      ...this.getChildContext(),
    });
    const container = this.leafletElement._icon;
    const component = <ContextProvider>{this.props.children}</ContextProvider>;
    if (container) {
      render(component, container);
    }
  };

  render() {
    return null;
  }
}

Here we're telling React to render nothing and instead mount a new tree inside the DivIcon which is updated separately. This is how react-leaflet-div-icon works and it's really the best we can do. Well, it was until...

A Better Solution: React Portals

React 16 introduces Portals. Portals let you mount React Components in completely different DOM elements from its parent while still being in the same React tree. The DOM no longer reflects a React tree so bits and pieces can be placed anywhere. Including a DivIcon!

So our code from before can simply become:

class CustomMarker extends MapLayer {
  // ...
  render() {
    const container = this.leafletElement._icon;

    if (!container) {
      return null;
    }

    return ReactDOM.createPortal(this.props.children, container);
  }
}

Simpler, lighter weight and easier to debug.

Here's the complete Component:

// CustomMarker.jsx
import PropTypes from "prop-types";
import { createPortal } from "react-dom";
import { DivIcon, marker } from "leaflet";
import { MapLayer } from "react-leaflet";
import { difference } from "lodash";

class CustomMarker extends MapLayer {
  getChildContext() {
    return {
      popupContainer: this.leafletElement,
    };
  }

  createLeafletElement(newProps) {
    const { map, layerContainer, position, ...props } = newProps;
    this.icon = new DivIcon(props);
    return marker(position, { icon: this.icon, ...props });
  }

  updateLeafletElement(fromProps, toProps) {
    if (toProps.position !== fromProps.position) {
      this.leafletElement.setLatLng(toProps.position);
    }
    if (toProps.zIndexOffset !== fromProps.zIndexOffset) {
      this.leafletElement.setZIndexOffset(toProps.zIndexOffset);
    }
    if (toProps.opacity !== fromProps.opacity) {
      this.leafletElement.setOpacity(toProps.opacity);
    }
    if (toProps.draggable !== fromProps.draggable) {
      if (toProps.draggable) {
        this.leafletElement.dragging.enable();
      } else {
        this.leafletElement.dragging.disable();
      }
    }
    if (toProps.className !== fromProps.className) {
      const fromClasses = fromProps.className.split(" ");
      const toClasses = toProps.className.split(" ");
      this.leafletElement._icon.classList.remove(
        ...difference(fromClasses, toClasses)
      );
      this.leafletElement._icon.classList.add(
        ...difference(toClasses, fromClasses)
      );
    }
  }

  componentWillMount() {
    super.componentWillMount();
    this.leafletElement = this.createLeafletElement(this.props);
    this.leafletElement.on("add", () => this.forceUpdate());
  }

  componentDidUpdate(fromProps) {
    this.updateLeafletElement(fromProps, this.props);
  }

  render() {
    const container = this.leafletElement._icon;

    if (!container) {
      return null;
    }

    return createPortal(this.props.children, container);
  }
}

CustomMarker.propTypes = {
  opacity: PropTypes.number,
  zIndexOffset: PropTypes.number,
};

CustomMarker.childContextTypes = {
  popupContainer: PropTypes.object,
};

export default CustomMarker;