2

This is a more generalized version of my other question: Remove Zoom control from map in react-leaflet. React-leaflet comes with a handful of components which control the map - i.e. the <ZoomControl />, <LayersControl />, etc. But in order for these components to properly communicate with a map instance, they must be written as a child of the <Map /> component, like so:

<Map center={...} zoom={...}>

  <ZoomControl />
  <LayersControl />
  <MyCustomComponent />

</Map>

What I am trying to create is a situation where map components that are not direct children of the map can properly communicate with the map. For example:

<App>
  <Map />
  <Sibling>
    <Niece>
      <ZoomControl />
      <OtherControls />
    </Niece>
  </Sibling>
</App>

Obviously the trouble here is that when these controls are no longer children or descendants of the <Map />, they don't receive the map instance through a provider. As you can see in my other question, I tried creating a new Context object and using it to provide the map to the displaced controls. That didn't work, and I'm not sure why. So far, my solution has been to use vanilla javascript to rehome these controls in the componentDidMount of their intended parent, like so:

// Niece.js
componentDidMount(){
  const newZoomHome = document.querySelector('#Niece')
  const leafletZoomControl= document.querySelector('.leaflet-control-zoom')
  newZoomHome.appendChild(leafletZoomControl)
}

I really hate this because now my general component structure does not reflect the application structure. My Zoom needs to be written as part of my map, but ends up in my Neice component.

Kboul's solution in my other question was simply to rebuild the zoom component from scratch and feed it the map context. This works fine for a simple zoom component, but for more complex components, I can't be rebuilding entire frameworks. For example, I made a quick react-leaflet component version of esri-leaflet's geosearch:

import { withLeaflet, MapControl } from "react-leaflet";
import * as ELG from "esri-leaflet-geocoder";

class GeoSearch extends MapControl {
  createLeafletElement(props) {
    const searchOptions = {
       ...props,
      providers: props.providers ? props.providers.map( provider => ELG[provider]()) : null
    };

    const GeoSearch = new ELG.Geosearch(searchOptions);
    // Author can specify a callback function for the results event
    if (props.onResult){
      GeoSearch.addEventListener('results', props.onResult)
    }
    return GeoSearch;
  }

  componentDidMount() {
    const { map } = this.props.leaflet;
    this.leafletElement.addTo(map);
  }
}

export default withLeaflet(GeoSearch);

Its relatively simple and works great when declared inside the <Map /> component. But I want to move it to a separate place in the app, and I don't want to have to recode the entire esri Geosearch. How can I use functioning react-leaflet control components outside of the <Map /> component while properly linking it to the map instance?

Here's a quick codsandbox template to start messing with if you're so inclined. Thanks for reading.

Seth Lutske
  • 9,154
  • 5
  • 29
  • 78

3 Answers3

2

You can use onAdd method to create a container for your plugin outside the map and then with the use of refs to add the element to the DOM like this:

class Map extends React.Component {
  mapRef = createRef();
  plugin = createRef();

  componentDidMount() {
    // map instance
    const map = this.mapRef.current.leafletElement;

    const searchcontrol = new ELG.Geosearch();
    const results = new L.LayerGroup().addTo(map);
    searchcontrol.on("results", function(data) {
      results.clearLayers();
      for (let i = data.results.length - 1; i >= 0; i--) {
        results.addLayer(L.marker(data.results[i].latlng));
      }
    });
    const searchContainer = searchcontrol.onAdd(map);
    this.plugin.current.appendChild(searchContainer);
  }

  render() {
    return (
      <>
        <LeafletMap
          zoom={4}
          style={{ height: "50vh" }}
          ref={this.mapRef}
          center={[33.852169, -100.5322]}
        >
          <TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
        </LeafletMap>
        <div ref={this.plugin} />
      </>
    );
  }
}

Demo

kboul
  • 13,836
  • 5
  • 42
  • 53
  • This didn't *exactly* answer my question, because you're saying that the GeoSearch component be added within the componentDidMount of the map. Your answers in the [other question](https://stackoverflow.com/a/59778024/12010984) explain how to bring the map context outside the map. This answer gives the crucial nugget of how to create a leaflet custom control and render it anywhere in the DOM, under the umbrella of the externalized context. I'm going to write up a sythesis of your answers here and there to help my understanding and mark this conundrum as solved. Thank you so much! – Seth Lutske Jan 20 '20 at 18:43
  • You say ''because you're saying that the GeoSearch component be added within the componentDidMount of the map. " So what? What is the limitation on this? It actually gives an answer because you say you do not want to build the entire library from scratch. The other answers as I mentioned are suitable if you want to rebuild a small component like the zoom and moreover avoiding passing any context anywhere. – kboul Jan 20 '20 at 19:41
  • From a "does it work?" perspective, there's no limitation, its a good solution. But my question here primarily is 'How can I use functioning react-leaflet control components outside of the `` component while properly linking it to the map instance?' Which this answer doesn't really do, because the control is created within the Map component. I am working on a map with many sub-components, and writing them all in this way would get messy. Its a style/organization difference I guess. – Seth Lutske Jan 20 '20 at 20:25
  • 1
    I marked your answer as an answer because it was so helpful though, again, thank you – Seth Lutske Jan 20 '20 at 20:33
2

Thanks to kboul's answer in this and my other question, I am going to write out an answer here. This is kboul's answer really, but I want to cement it in my brain by writing it out, and have it available for anyone that stumbles by.

First, we need to create a context object, and a provider for that context. We'll create two new files in the directory for easy access from other files:

/src
  -App.js
  -Map.js
  -MapContext.js
  -MapProvider.js
  -Sibling.js
  -Niece.js
  /Components
    -ZoomControl.js
    -OtherControls.js

Create an empty context object:

// MapContext.jsx
import { createContext } from "react";

const MapContext = createContext();

export default MapContext

Next, we use the MapContext object to create a MapProvider. The MapProvider has its own state, which holds a blank reference which will become the map reference. It also has a method setMap to set the map reference within its state. It provides as its value the blank reference, as well as the method to set the map reference. Finally, it renders its children:

// MapProvider.jsx

import React from "react";
import MapContext from "./MapContext";

class MapProvider extends React.Component {
  state = { map: null };

  setMap = map => {
    this.setState({ map });
  };

  render() {
    return (
      <MapContext.Provider value={{ map: this.state.map, setMap: this.setMap }}>
        {this.props.children}
      </MapContext.Provider>
    );
  }
}

export default MapProvider;

Now, within the Map component, we will export a map wrapped in the MapProvider.

// Map.jsx

import React from "react";
import { Map as MapComponent, TileLayer, Marker, etc } from 'react-leaflet'
import MapContext from './MapContext'

class Map extends React.Component{

  mapRef = React.createRef(null);

  componentDidMount() {
    const map = this.mapRef.current.leafletElement;
    this.props.setMap(map);
  }

  render(){

    return (

      <MapComponent 
         center={[centerLat, centerLng]} 
         zoom={11.5} 
         ...all the props
         ref={this.mapRef} >

      </MapComponent>

    );
  }
}

const LeafletMap = props =>  (
  <MapContext.Consumer>
    {({ setMap }) => <Map {...props} setMap={setMap} />}
  </MapContext.Consumer>
)

export default LeafletMap

In this last step, we don't export the Map, but rather we export the Map wrapped in the provider, with the {value} of the MapProvider as the Map's props. In this way, when the LeafletMap is called in the App component, on componentDidMount, the setMap function will be called as a prop, calling back to the MapProvider setMap function. This sets the state of the MapProvider to have the reference to the map. But this does not happen until the map is rendered in App:

// App.js

class App extends React.Component{

  state = { mapLoaded: false }

  componentDidMount(){
    this.setState({ mapLoaded:true })

  }

  render(){
    return (
      <MapProvider>
        <LeafletMap  />
        {this.state.mapLoaded && <Sibling/>}
      </MapProvider>
    )
  }

}

Note that the setMap method of the MapProvider is not called until the LeafletMap componentDidMount fires. So upon render in App, there's not yet a context value, and any component within Sibling that tries to access the context will not have it yet. But once App's render runs, and LeafletMaps componentDidMount runs, setMap runs, and the map value is the Provider is available. So in App, we wait until the componentDidMount runs, at which point setMap has already run. We set the state within the App that the map is loaded, our conditional render statement for Sibling will render the Sibling, with all of its children, with the MapContext object properly referencing the map. Now we can use it in a component. For example, I rewrote the GeoSearch component to work like so (thanks to kboul's suggestion):

// GeoSearch

import React from 'react'
import MapContext from '../Context'
import * as ELG from "esri-leaflet-geocoder";

class EsriGeoSearch extends React.Component {

   componentDidMount() {

      const map = this.mapReference

      const searchOptions = {
         ...this.props,
        providers: this.props.providers ? this.props.providers.map( provider => ELG[provider]()) : null
      };
      const GeoSearch = new ELG.Geosearch(searchOptions);

      const searchContainer = GeoSearch.onAdd(map);
      document.querySelector('geocoder-control-wrapper').appendChild(searchContainer);

   }


  render() {
     return (
        <MapContext.Consumer>
           { ({map}) => {
              this.mapReference = map
              return <div className='geocoder-control-wrapper' EsriGeoSearch`} />
           }}
        </MapContext.Consumer>
     )
  }
}

export default EsriGeoSearch;

So all we did here was create an Esri GeoSearch object, and store its HTML and associated handlers in a variable searchContainer, but did not add it to the map. Rather we create a container div where we want it in our DOM tree, and then on componentDidMount, we render that HTML inside of that container div. So we have a component that is written and rendered in its intended place in the application, which properly communicates with the map.

Sorry for the long read, but I wanted to write out the answer to cement my own knowledge, and have a fairly canonical answer out there for anyone who may find themselves in the same situation in the future. The credit goes 100% to kboul, I'm just synthesizing his answers into one place. He's got a working example here. If you like this answer please upvote his anwers.

Seth Lutske
  • 9,154
  • 5
  • 29
  • 78
1

Might not be helpful in this instance but I used redux to define the state of the map and then used normal actions and reducers to update the map from anywhere in the application.

So your action would look something like this

export const setCenterMap = (payload) => ({
  type: CENTER_MAP,
  payload,
})

and a basic reducer:

const initialState = {
centerMap: false,
}

export const reducer = (state = initialState, action) => {
    switch (action.type) {
        case (CENTER_MAP) : {
            return ({
                ...state,
                centerMap: action.payload
            })
        }
        default: return state
    }
}

Then you connect it to your map component

const mapStateToProps = state => ({
  centerMap: state.app.centerMap,
})

const mapDispatchToProps = dispatch => ({
  setCenterMap: (centerMap) => dispatch(setCenterMap(centerMap)),
})

You can now manipulate the map outside of the Leaflet component.

        <LeafletMap
            center={centerMap}
            sites={event.sites && [...event.sites, ...event.workingGroups]}
        />
        <button onClick={() => setCenterMap([5.233, 3.342])} >SET MAP CENTER</button>

Most of this is pseudo code so you would have to adopt it for your own use but I found it a fairly painless way to add some basic map controls from outside of the LeafletMap component especially if you are already using redux.

Charlie Tupman
  • 519
  • 6
  • 11
  • Thanks for your thoughts on this. This is essentially what I'm doing in my larger-scale apps that use redux. But it doesn't really enable me to use *prebuilt* map components outside the `` instance, while still communicating with it. For example, what set of actions would I have to write to get the functionality of ``. As I mentioned, "I don't want to have to recode the entire esri Geosearch." Your answer is great for isolated bits and peices of actions, but not for passing context to entire components. Thank you for your thoughts. – Seth Lutske Jun 02 '20 at 15:42