Search code examples
reactjsrefreact-map-gl

React ref.current is still null in componentDidUpdate


I'm trying to zoom-in on a set of coordinates using react-map-gl. To do so, the library encourages us to use React's Refs. When instantiating the component in render, I'm passing down a ref that should hold the underlying MapBox map object. I managed to make the behavior work. Only problem: it's highly inconsistent.

I'm calling a method called setCamera in componentDidUpdate. But it only works on the initial load. If I reload the page, I've got an error from Gatsby. If I close the error it will work again. Up until the next reload.

The error is that this.mapRef.current is null. I tried to put a conditional to verify that the value isn't null before trying to access it, however this cause the animation to just never work. No matter how many times I reload, it will never be performed. Whereas without the conditional it could at least work half the time before crashing. So this already is a mystery in itself and if someone has an idea for why such behavior can happen, I'm all ears.

Still, I wasn't discouraged and tried to put the call to setCamera in a setTimeout and yes, it works! Even putting a very low timeout like 1 makes the code work 95% of the time. But I'm unsatisfied with it, because I understand that putting that kind of timers isn't what I'm supposed to do and to make things worse, it doesn't fix the issue consistently.

My understanding of the problem is that MapRef is still not set in componentDidUpdate for some reason. It's being set sometimes later. I don't know if React supports threading or if some kind of async witchcraft is deceiving me behind the scenes, but what I'm wondering is when am I guaranteed for my ref to be properly set? Where or how should I write this code?

Thank you in advance to anyone who can help me on that. 🙂

Here's my sample code:

import React, {Component} from 'react';
import MapGL, {NavigationControl, Source, Layer} from 'react-map-gl';
import 'mapbox-gl/dist/mapbox-gl.css';
import gpxParser from 'gpxparser';
import bbox from '@turf/bbox';

const MAPBOX_TOKEN = 'exampleToken'

class HikeMapbox extends Component {
    constructor(props) {
        super(props)

        this.state = {
            gpxData: '',
        };
    }

    // Read et get the GPX file, return it as a bunch of text
    componentDidMount() {
        const gpxPath = `https:${this.props.gpxPath}`;

        // Simple GET request using fetch
        fetch(gpxPath)
            .then(response => response.text())
            .then(text => this.setState({ gpxData: text }));
    }

    componentDidUpdate() {
        // Set the camera on didUpdate
        setTimeout(() => {
            const geoJsonData = this.getGeoJson();
            this.setCamera(geoJsonData);
        }, 10);
    }

    // Get Max and Min Lat and Lon to Zoom on the GPX trace
    setCamera(geoJsonData) {
        if (geoJsonData) {
            const [minLng, minLat, maxLng, maxLat] = bbox(geoJsonData);
            this.mapRef.current.fitBounds(
                [
                    [minLng, minLat],
                    [maxLng, maxLat]
                ],
                {padding: 40}
            );
        }
    }

    // Take the text and parse it as geoJSON
    getGeoJson() {
        const { gpxData } = this.state;
        let gpx = new gpxParser();
        
        try {
            gpx.parse(gpxData);
        } catch (err) {
            console.log(err);
        }

        const geoJson = gpx.toGeoJSON();   

        return geoJson
    }

    // Return the GPX trace in Mapbox
    showGpxFile() {
        const GeoJsonData = this.getGeoJson();

        // Choose the style of the GPX trace
        const layerStyle = {
            id:'contours',
            type:'line',
            source:'contours',
            paint: {
                'line-color': 'blue',
                'line-width': 3
            }
        };

        return (
            <>
                {
                // Return the React Mapbox GL code to show the GPX file on the map
                GeoJsonData && (
                    <Source type="geojson" data={GeoJsonData}>
                        <Layer {...layerStyle} />
                    </Source>
                )
                }
            </>
        )
    }


  render() {
      this.mapRef = React.createRef();
      return (
        <>
        <MapGL
            ref={this.mapRef}
            initialViewState={{
              latitude: this.props.latitude,
              longitude: this.props.longitude,
              zoom: 8,
            }}
            style={{width: "100%", height: "100%"}}
            mapStyle="mapbox://styles/mapbox/outdoors-v11"
            mapboxAccessToken={MAPBOX_TOKEN}
        >
        <NavigationControl />
        {this.showGpxFile()}
        </MapGL>
        </>
    )
  }
  
}

export default HikeMapbox;

By the way, I'm running this code on my computer using gatsby develop. I don't know if that could be related, but I thought it could be relevant.


Solution

  • I've found a solution!

    My issue was that setCamera was dependent on two conditions and these two conditions could happen in any order.

    1. Fetch is successful and we have data to display.
    2. The map is loaded and we have a ref to it.

    Instead of initializing mapRef, in the constructor or in render, I've made a function…

    onMapRefChange = node => {
        this.setState({mapRef: node});
        // I'm calling my method now
        this.setCamera()
    };
    

    … That I'm passing in in the ref parameter

    <MapGL
        ref={this.onMapRefChange}
        ...
    

    Eventually onMapRefChange will receive an actual map object and then the code in setCamera will be able to access it.