Mapbox GL JS in a Reactive application

Tom MacWright
Mon, 22 Aug 2016 12:00:00 -0400

We use React to build Mapbox Studio, demos, and many other parts of our website. React, along with Redux, and Immutable.js, forms a new standard for building web applications that yields good and robust results.

Reactive-style display

A trademark of the React way of doing things, as well as its alternatives like virtual-dom, cycle.js, or deku, is that you define the HTML you want for each state of your application and then trust the library to efficiently make that the truth. For instance, in the canonical ‘timer’ example:

var Timer = React.createClass({
  getInitialState: function() {
    return {secondsElapsed: 0};
  },
  tick: function() {
    this.setState({secondsElapsed: this.state.secondsElapsed + 1});
  },
  componentDidMount: function() { this.interval = setInterval(this.tick, 1000); },
  componentWillUnmount: function() { clearInterval(this.interval); },
  render: function() {
    return (
Seconds Elapsed: {this.state.secondsElapsed}
    );
  }
});

ReactDOM.render(<Timer />, mountNode);

If you were using jQuery, you’d probably render the initial page from a template or static file, and then reach into the page to modify the number of seconds elapsed:

$('#seconds-elapsed').text(secondsElapsed);

Here, you, the human, are optimizing. You know that the rest of the page stays the same - the div element, the other text - so you’re only changing part of it.

In the React version, React does this optimization. It decides which parts of your page stay the same and which change, and it figures out the most efficient way to go from A to B. If your render method describes a page with a red button and then you run render again and the button is now declared to be blue, React will try to find the minimum number of steps to go from one to the other. It won’t use .innerHTML to replace the whole page, and it might not even need to replace the button element - simply adding a class or style property might do the job.

So we’re replacing imperative methods like .text() or .setAttribute() with a full description of ‘what we want the UI to be,’ as decided by the render() method. This way React components don’t need to respond to any data changes in particular: there are no piecemeal changes to the page because an input was toggled. Instead, every change can be thought of a ‘render from scratch’, but the system ensures re-renders will be fast.

Mapbox GL JS

How can Mapbox GL JS fit into a reactive-style system like React.js?

The React/Redux system works perfectly for a tool like Mapbox Studio: it deals with complex, interconnected data - map styles and features - and complex interactions. A color change on the map might come from someone typing a new color manually, or from a click on a color picker, or even from someone hitting “Undo” and “Redo.” For the tool to be immersive, all of these changes need to be fast and smooth.

But Mapbox GL JS’s APIs were very low-level and imperative. Calling setStyle() replaced a complete style from scratch, causing an unpleasant flash. We could call other methods, like setPaintProperty, to make incremental and fast changes to certain parts of the style, but this connects specific UI actions with UI reactions, which cuts against our intent and makes the application brittle.

We decided that we wanted the equivalent of React’s render() call: a single method that you can call with a complete map style and that intelligently transitions from its current state to the new one. So we ended up writing a “diff” algorithm that started in Studio and ended up in mapbox-gl-style-spec: diffStyles. How it works is that you give it two Mapbox GL styles, and it returns an array of commands like:

[
{ command: 'setConstant', args: ['@water', '#0000FF'] },
{ command: 'setPaintProperty', args: ['background', 'background-color', 'black'] }
]

Applying these changes is pretty simple: each command is a method on the map object, and we can use JavaScript’s Function.prototype.apply method to call it with any number of arguments, stored in args.

changes.forEach(function(change) {
  map[change.command].apply(map, change.args);
});

When we started, there weren’t efficient functions for many of these changes, but we’ve rallied for more and more low-level APIs in Mapbox GL JS and come up with a good experience. There are still missing links - sprites, for instance, can’t be modified programmatically. When there’s no efficient way to turn one style into another, we fall back on a setStyle call.

With this system in place, we manage the stylesheet entirely within a Redux reducer, and within an immutable data structure with Immutable.js. As a result, we can easily show and control values of every property in the stylesheet. We then treat the map as only an output mechanism, never storage for the style. We set the map’s style, but never refer back to it, or use any getter methods.

Events

That’s how we do style updates in Mapbox Studio. “One true way to render” is a big part of the reactive philosophy. Let’s cover another one: events.

Since React manages user-initiated events, you can add event listeners like:

render: function() {
  return (

  );
}

Importantly, when the value of this.state.text changes, React updates it in that text input, but doesn’t call this.onChange. This breaks the potentially infinitely recursive loop that happens when your program responds to its own output.

Initially, this was a problem with Mapbox GL JS. We have many ways of controlling the map - through keybindings, the geocoder, saved initial map position, text inputs, and mouse control - as well as ways of displaying it, mainly in the map controls user interface element. Controlling the map by moving it with a function like jumpTo also fires events like move, and we would react to those events by updating the position of the map in our Redux reducers, and disaster ensued.

Luckily, this issue could be avoided by a small improvement to the Mapbox GL JS API. Bryan contributed a change that adds a .originalEvent property to user-initiated events. So to only subscribe to move events caused by humans, you can write:

map.on('move', function(event) {
  if (event.originalEvent) {
    console.log('A human moved the map');
  }
});

This way we disconnect the accidental recursive loop and we can store the map’s zoom, latitude, and longitude in a reducer.

More context

React’s tips for connecting with other libraries apply readily to Mapbox GL JS. There are also several React wrappers for Mapbox GL - Uber’s react-map-gl project wraps GL JS, and our react-native-mapbox-gl project wraps Mapbox GL Native for use with React Native.

Thanks to the diffStyles method, our integration between Mapbox GL JS and Mapbox Studio doesn’t need a module or strong abstraction: we can interact with the low-level parts of Mapbox GL JS if we need to. With a few good patterns in place, Mapbox GL JS flows beautifully into the rest of a modern, reactive application.