Search code examples
reactjsleafletreact-leafletreact-state

React-Leaflet does not render Map on the entire state after update


My web-app, for some reason, isn't fully updating when the state changes.

Let me explain: I have a React-Leaflet Map that I wish to update anytime a user changes filtering criteria. For instance, changing the city where some Markers appear. For this purpose I've built a backend that loads some JSON from an endpoint, my web-app fetches it, and then successfully updates the items array where I store the data I need. After this, the new Markers are indeed added to the Map.

What is not updated is the zoom and the center of my MapContainer, despite the functions that take care of this are correctly executed when the component is mounted.

So, in short, inside componentDidMount I fetch data which is then passed to the state which is used to populate and render my Map. After that, if the user presses the filter button and inserts a new city my componentDidUpdate recognizes that props have changed, it then fetches new data. Still, my Map only re-renders adding the new Markers without setting the new zoom and the new center.

Would anyone be so kind as to help me on this one? Huge thanks in advance.


import React from "react";
import { MapContainer, TileLayer } from "react-leaflet";
import MyMarker from "./MyMarker";
import "./MapObject.css";

/*
  IMPORTANT: We are NOT taking into consideration the earth's curvature in
             neither getCenter nor getZoom. This is only left as it is for
            the time being because it is not mission critical
*/

// Function to calculate center of the map based on latitudes and longitudes in the array
function getCenter(json) {
  // Array to store latitude and longitude
  var lats = [];
  var lngs = [];
  const arr = Object.keys(json).map((key) => [key, json[key]]);

  // Loop through the array to get latitude and longitude arrays
  for (let i = 0; i < arr.length; i++) {
    lats.push(arr[i][1].Latitude);
    lngs.push(arr[i][1].Longitude);
  }
  const lat = lats.reduce((a, b) => a + b) / lats.length;
  const lng = lngs.reduce((a, b) => a + b) / lngs.length;
  return [lat, lng];
}

// Function to get the zoom level of the map based on the number of markers
function getZoom(json) {
  // Array to store latitude and longitude
  var lats = [];
  var lngs = [];
  const arr = Object.keys(json).map((key) => [key, json[key]]);

  // Loop through the array to get latitude and longitude arrays
  for (let i = 0; i < arr.length; i++) {
    lats.push(arr[i][1].Latitude);
    lngs.push(arr[i][1].Longitude);
  }
  const zoom = Math.floor(Math.log2(lats.length)) + 8;
  return zoom;
}

export default class MapObject extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      map: null,
      dataIsLoaded: false,
      zoom: null,
      position: [null, null],
      items: [],
    };
  }

  changePos(pos) {
    this.setState({ position: pos });
    const { map } = this.state;
    if (map) map.flyTo(pos);
  }

  fetchData(filter, param) {
    fetch(`https://my-backend-123.herokuapp.com/api/v1/${filter}/${param}`)
      .then((res) => res.json())

      .then((json) => {
        this.setState(
          {
            items: json,
            dataIsLoaded: true,
            zoom: getZoom(json),
            position: [getCenter(json)[0], getCenter(json)[1]],
          },
          () => {
            console.log("State: ", this.state);
            this.changePos(this.state.position);
          }
        );
      });
    console.log(
      "Fetched new data: " +
        "DataisLoaded: " +
        this.state.dataIsLoaded +
        " " +
        "Zoom: " +
        this.state.zoom +
        " " +
        "Lat: " +
        this.state.position[0] +
        " " +
        "Lng: " +
        this.state.position[1]
    );
  }

  componentDidMount() {
    this.fetchData("city", this.props.filterValue);
  }

  componentDidUpdate(prevProps) {
    if (prevProps.filterValue !== this.props.filterValue) {
      this.fetchData("city", this.props.filterValue);
      //MapContainer.setCenter([getCenter(this.state.items)[0], getCenter(this.state.items)[1]]);
    }
  }

  render() {
    // Logic to show skeleton loader while data is still loading from the server
    const { dataIsLoaded, items } = this.state;
    console.log("Rendering!");

    if (!dataIsLoaded)
      return (
        <div className="bg-white p-2 h-160 sm:p-4 rounded-2xl shadow-lg flex flex-col sm:flex-row gap-5 select-none">
          <div className="h-full sm:h-full sm:w-full rounded-xl bg-gray-200 animate-pulse"></div>
        </div>
      );

    // Logic to show map and markers if data is loaded
    return (
      <div>
        <MapContainer
          id="mapId"
          whenCreated={(map) => this.setState({ map })}
          attributionControl={true} // remove Leaflet attribution control
          center={this.state.position}
          zoom={this.state.zoom}
          scrollWheelZoom={false}
        >
          <TileLayer
            attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
            url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
          />
          {this.state.map}
          {items.map((item, index) => (
            <div key={index}>
              <MyMarker
                name={item.Name}
                city={item.City}
                prov={item.Province}
                lat={item.Latitude}
                lng={item.Longitude}
                phone={item["Phone Number"]}
              />
            </div>
          ))}
        </MapContainer>
      </div>
    );
  }
}


I'm leaving a GIF down below to showcase the current behavior. You can also briefly notice that the Markers "disappear": that's because the new Markers have been rendered taking into account the new filter, that's why I then proceed to manually zooming out to show that the new Markers have been indeed rendered.

Demo of the bug

EDIT: I am now trying to implement this solution, but I’ve been unsuccessful so far.


Solution

  • Alrightie, I decided to fully refactor my code and to switch to a function component, from the class component I had earlier.

    The solution I've implemented comes from this answer.

    The key was to add the following function to my code:

    /*
     Function to move the map to the center of the markers after the map
     is loaded with new data and with the correct zoom.
    */
    function FlyMapTo(props) {
      const map = useMap();
    
      useEffect(() => {
        map.flyTo(props.center, props.zoom);
      });
    
      return null;
    }
    

    After that, I add this component to the JSX that takes care of rendering the parent component (my Map.js):

    return (
      <MapContainer center={center} zoom={zoom} scrollWheelZoom={true}>
        <TileLayer
          attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
          url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
        />
        {items.map((item, index) => (
          <div key={index}>
            <MyMarker
              name={item.Name}
              city={item.City}
              prov={item.Province}
              lat={item.Latitude}
              lng={item.Longitude}
              phone={item["Phone Number"]}
            />
          </div>
        ))}
        <FlyMapTo center={center} zoom={zoom} />
      </MapContainer>
    );
    

    Just for reference, should anyone face a similar problem, I'm adding the full code of my new Map.js component:

    import React, { useState, useEffect } from "react";
    import "./MapObject.css";
    import { MapContainer, TileLayer } from "react-leaflet";
    import MyMarker from "./MyMarker";
    import { useMap } from "react-leaflet";
    
    /*
      IMPORTANT: We are NOT taking into consideration the earth's curvature in
                 neither getCenter nor getZoom. This is only left as it is for
                the time being because it is not mission critical
    */
    
    // Function to calculate center of the map based on latitudes and longitudes in the array
    function getCenter(json) {
      // Array to store latitude and longitude
      var lats = [];
      var lngs = [];
      const arr = Object.keys(json).map((key) => [key, json[key]]);
    
      // Loop through the array to get latitude and longitude arrays
      for (let i = 0; i < arr.length; i++) {
        lats.push(arr[i][1].Latitude);
        lngs.push(arr[i][1].Longitude);
      }
      const lat = lats.reduce((a, b) => a + b) / lats.length;
      const lng = lngs.reduce((a, b) => a + b) / lngs.length;
      return [lat, lng];
    }
    
    // Function to get the zoom level of the map based on the number of markers
    function getZoom(json) {
      // Array to store latitude and longitude
      var lats = [];
      var lngs = [];
      const arr = Object.keys(json).map((key) => [key, json[key]]);
    
      // Loop through the array to get latitude and longitude arrays
      for (let i = 0; i < arr.length; i++) {
        lats.push(arr[i][1].Latitude);
        lngs.push(arr[i][1].Longitude);
      }
      const zoom = Math.floor(Math.log2(lats.length)) + 8;
      return zoom;
    }
    
    // Function to move the map to the center of the markers after the map is loaded with new data
    function FlyMapTo(props) {
      const map = useMap();
    
      useEffect(() => {
        map.flyTo(props.center, props.zoom);
      });
    
      return null;
    }
    
    export default function Map(props) {
      const [zoom, setZoom] = useState(null);
      const [center, setCenter] = useState([null, null]);
      const [items, setItems] = useState([]);
      const [dataIsLoaded, setDataIsLoaded] = useState(false);
    
      useEffect(() => {
        fetch(
          `https://my-backend-123.herokuapp.com/api/v1/${props.filterType}/${props.filterValue}`
        )
          .then((res) => res.json())
          .then((json) => {
            if (json["detail"] === "Not Found") {
              setItems([]);
              setDataIsLoaded(false);
            } else {
              setItems(json);
              setDataIsLoaded(true);
              setCenter(getCenter(json));
              setZoom(getZoom(json));
            }
          });
      }, [props.filterType, props.filterValue]);
    
      // If data is not loaded, show an empty map
      if (!dataIsLoaded && items.length === 0) {
        return (
          <MapContainer
            center={{ lat: 45.0, lng: 12.0 }}
            zoom={5}
            scrollWheelZoom={true}
          >
            <TileLayer
              attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
              url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
            />
          </MapContainer>
        );
        // If data is loaded, show the map with markers
      } else if (dataIsLoaded) {
        return (
          <MapContainer center={center} zoom={zoom} scrollWheelZoom={true}>
            <TileLayer
              attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
              url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
            />
            {items.map((item, index) => (
              <div key={index}>
                <MyMarker
                  name={item.Name}
                  city={item.City}
                  prov={item.Province}
                  lat={item.Latitude}
                  lng={item.Longitude}
                  phone={item["Phone Number"]}
                />
              </div>
            ))}
            <FlyMapTo center={center} zoom={zoom} />
          </MapContainer>
        );
      }
    }