I have the following code which renders a React Leaflet v3 map and allows users to construct a route by clicking at various points on the map. The total route distance is displayed below the map.
interface RouteProps {
updateDistance: (d: number) => void;
}
function Route(props: RouteProps) {
const initialRoute: [number, number][] = [];
const [route, setRoute] = useState(initialRoute);
const calculateDistance = (point1: LatLng, point2: LatLng): number => {
// Omitted for brevity - it returns the distance between point1 and point2
}
const map = useMapEvents({
click(e: any) {
setRoute((prevValue) => {
let lastPoint = null;
if (prevValue.length > 0) {
lastPoint = prevValue[prevValue.length - 1];
const dist = calculateDistance(new LatLng(lastPoint[0], lastPoint[1]), new LatLng(e.latlng.lat, e.latlng.lng));
console.log('marker added, calling updateDistance', dist)
props.updateDistance(dist);
}
return [...prevValue, [e.latlng.lat, e.latlng.lng]];
});
map.panTo(e.latlng);
}
});
return (
<React.Fragment>
{route.map((point, idx) => <Marker key={idx} position={new LatLng(point[0], point[1])}></Marker>)}
<Polyline pathOptions={{fillColor: 'blue'}} positions={route} />
</React.Fragment>
);
}
function App() {
const [distance, setDistance] = useState(0);
const updateDistance = (d: number) => {
console.log('update distance called', d)
setDistance((prevValue) => {
console.log('setting new distance', d);
return prevValue + d
});
};
return (
<React.Fragment>
<MapContainer
center={[55, -2]}
zoom={12}
scrollWheelZoom={false}
style={{ width: "100vw", height: "80vh" }}
>
<TileLayer
attribution='© <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<Route updateDistance={updateDistance} />
</MapContainer>
<h6>Distance: {distance.toFixed(2)} miles</h6>
</React.Fragment>
);
}
A StackBlitz of this can be found here. Please note the markers don't render properly on the map, but that shouldn't matter for the purposes of the demo.
With this approach, I define distance
in the top level component using useState
and pass a handler that updates the total distance to the Route
component:
const updateDistance = (d: number) => {
console.log('update distance called', d)
setDistance((prevValue) => {
console.log('setting new distance', d);
return prevValue + d
});
};
<MapContainer ...>
...
<Route updateDistance={updateDistance} />
</MapContainer>
The click handler defined within useMapEvents
updates the route
state (which exists on in the Route
component), and also calls the updateDistance
function passed in via props
:
const map = useMapEvents({
click(e: any) {
setRoute((prevValue) => {
let lastPoint = null;
if (prevValue.length > 0) {
lastPoint = prevValue[prevValue.length - 1];
const dist = calculateDistance(new LatLng(lastPoint[0], lastPoint[1]), new LatLng(e.latlng.lat, e.latlng.lng));
console.log('marker added, calling updateDistance', dist)
props.updateDistance(dist);
}
return [...prevValue, [e.latlng.lat, e.latlng.lng]];
});
map.panTo(e.latlng);
}
});
When points are added to the route, I can see updateDistance
(and therefore setDistance
) are getting called twice. Open the console in the above StackBlitz to see the output.
I can "fix" the issue by defining the route
state in the top level component and passing it through to the Route
component as can be seen in this StackBlitz. Although this works, my rationale behind defining the route
state within the Route
component and the distance
state in the top level component, is that the top level component only needs to know about the distance and not details of the actual route.
I'm trying to understand why the first StackBlitz behaves like it does. I don't believe I'm mutating state anywhere and am always returning a new value for the both the route
and distance
state, and I'm also using the functional version of setState
where a state update needs to use the value of the previous state.