Search code examples
javascriptreactjstabsfrontendopenlayers

Managing an OpenLayers Map in a React Tab Component


I'm building a SPA app with a navigation bar with various tabs. When a tab is selected, I dispatch an action through redux, and the AppBody component rerenders itself according to which tab is selected (see renderAppBody() in AppBody).

My issue is that one of the tabs must contain an OpenLayers map. I had a lot of trouble getting the map to mount in the first place as it must target an element that already exists on the DOM. I did this by overriding componentDidMount:

MapBody.js

import React from 'react';
import "ol/ol.css"
import Map from 'ol/map'
import View from 'ol/view'
import Tile from 'ol/layer/tile'
import OSM from 'ol/source/osm'

import "../../sass/App.scss";


var map = new Map({
  layers: [
    new Tile({
      source: new OSM()
    }),
  ],
  view: new View({
    center: [0, 0],
    zoom: 4
  })
});


const MapBody = React.createClass({
  render: function () {
    return (<div id="map"></div>);
  },
  componentDidMount: function () {
    map.setTarget("map");
  }
});

export default MapBody;

This means that whenever I switch tabs, my Map element is destroyed / removed and replaced with the content for the next tab. The effect is that the first time I select the map tab it renders, but if I then select another tab and then reselect the map tab the map will not render.

Is my approach inherently flawed for the outcome that I want? This is my first largish React project, so I'm unsure of the best way to engineer this behavior.

Should I:

  • Maintain a reference to the map item and reappend it on rerender? (seems problematic)
  • Hide tabs instead of replacing them? (also seems problematic)
  • Make MapBody have two states: visible and inactive. Then always render it to the screen, and hiding it with CSS based on its state (seems hacky)
  • Something more elegant?

AppBody.js

import React from 'react';
import { connect } from 'react-redux';
import Activity from '../components/body/Activity'
import CategoryBody from '../components/body/CategoryBody'
import MapBody from '../components/body/MapBody'
import Search from '../components/body/Search'

import tabs from "../actions/tabDefinitions.js"

import "../sass/App.scss";

function renderAppBody(activeTab) {
  switch (activeTab) {
    case tabs.T_CATEGORIES:
      return <CategoryBody />
    case tabs.T_MAP:
      return (<MapBody />)
    case tabs.T_SEARCH:
      return <Search />
    case tabs.T_ACTIVITY:
      return <Activity />
    default:
      return <div>Oops! Something went wrong :(</div>
  }
}

const AppBody = ({activeTab}) => (
  <div className="frame_wrapper">
      {renderAppBody(activeTab)}
  </div>
)


const mapStateToProps = (state) => ({
  activeTab: state.activeTab,
});

export default connect(mapStateToProps)(AppBody);

Thanks!


Solution

  • I ended up always adding the MapBody component to AppBody regardless of the selected tab. I then added an active prop to MapBody to add show / hide CSS classes. That way the map container is never removed / reset when the tab is changed, so the state is maintained nicely. My approach doesn't feel particularly elegant (read: it's a dirty, dirty hack), but it works nonetheless.

    My MapBody render function became:

    render: function () {
        return (<div id="map"
                     className={this.props.active ? "active_map" : "inactive_map"}>
                </div>);
    }
    

    by adding the following CSS mostly gave the desired behavior:

    .inactive_map {
       display: none;
    }
    

    This caused the map container to be properly rendered / hidden when switching tabs, but the map canvas was gray and didn't render any content. To fix this, I overrode the componentDidUpdate() to call map.updateSize(), which forces a recalculation of the map viewport size. Final code:

    MapBody.js

    const MapBody = React.createClass({
      render: function () {
        return (<div id="map"
                     className={this.props.active ? "active_map" : "inactive_map"}>
                </div>);
      },
      componentDidMount: function () {
        this.props.map.setTarget("map");
      },
      componentDidUpdate: function () {
        this.props.map.updateSize();
      }
    });
    

    AppBody.js

    function renderAppBody(activeTab) {
      switch (activeTab) {
        case tabs.T_CATEGORIES:
          return <CategoryBody />
        case tabs.T_MAP:
          return "";
        case tabs.T_SEARCH:
          return <Search />
        case tabs.T_ACTIVITY:
          return <Activity />
        default:
          return (<div>Oops! Something went wrong :(</div>)
      }
    }
    
    const AppBody = ({activeTab}) => (
      <div className="frame_wrapper">
          {renderAppBody(activeTab)}
          <Map active={activeTab == tabs.T_MAP}/>
      </div>
    )