Day 12: Showing user location on embedded Google Maps (with Geolocation API and React)

My location is shown, with the range of location error, on embedded Google Maps when I was visiting a cafe in Rakusaiguchi (a suburban district in the west of Kyoto) (screenshot by the author)

TL;DR

To create a web app that shows the user’s location on embedded Google Maps with React:

  1. Create a state variable that stores an instance of Google Maps, and pass this state to a button component as its prop (Section 1).
  2. Once the button is clicked, use Geolocation API to retrieve location data from the user’s device, and execute the setCenter() method of Google Maps JavaScript API to snap the map to the user's location (Section 2).
  3. To mark the user’s location on the map, use google.maps.Marker() method of Google Maps JavaScript API (Section 3).
  4. To show the range of location data error, use google.maps.Circle() method to draw a circle whose radius is set in meters (Section 4).
  5. To handle Geolocation API errors, update the UI state for each error case (Section 5.3).

In doing so, we need to use React’s useRef hook to retain the maker for the user's location across the re-rendering of React components, a lesser-known technique of making a React app (Section 3.2).

Introduction

Showing the user’s location on the map is an important feature of My Ideal Map App, a web app that I’m building to improve the user experiences of Google Maps. It allows the user to discover which of their saved places (e.g., cafes that they always wanted to go) are close enough to visit now (see Day 1 of this blog series for detail).

Unlike Google Maps iOS/Android app, however, a web app cannot (and should not try to) show the user’s location immediately after the user accesses the app (see Day 11 of this blog series for detail).

The second best option is therefore to show the user’s location only after the user taps a button on the screen.

How to implement such a feature is well-described in the code snippet provided by Google Maps Platform documentation. But it is for vanilla JavaScript. I’m using React (Next.js, to be more exact) to build My Ideal Map App. And I’ve gone through a handful of sticking points due to how React works.

For those of you who also create a React app with embedded Google Maps, let me share with you what I have learned to show the user’s location on the map.

Demo

This article will create an app like this demo hosted on Cloudflare Pages. Maybe you want to check it out before reading the rest of this article.

1. Setting up

Let me first quickly go through how to embed Google Maps and to render a button over it.

Write the component for the index page (or pages/index.js in Next.js) as follows:

// pages/index.jsimport LocatorButton from '../components/LocatorButton';
import Map from '../components/Map';
function HomePage() {
const [mapObject, setMapObject] = useState(null);
return (
<>
<LocatorButton mapObject={mapObject} />
<Map setMapObject={setMapObject} />
</>
);
}
export default HomePage;

The mapObject state variable will store an instance of the embedded Google Maps. The <Map> component will embed Google Maps, pass it to pages/index.js by executing the setMapObject() method. Then the pages/index.js will hand it over to the <LocatorButton> which will mark the user's current location on the embedded Google Maps.

The <Map> component embeds Google Maps with the following code (if the code below is perplexing, see my blog post (Kudamatsu 2021) in which I explain how to embed Google Maps with Next.js):

// components/Map.jsimport {useEffect, useRef} from 'react';
import {Loader} from '@googlemaps/js-api-loader';
import PropTypes from 'prop-types';
const Map = ({setMapObject}) => {
// Specifying HTML element to which Google Maps will be embeded
const googlemap = useRef(null);
useEffect(() => {
// Loading Google Maps JavaScript API
const loader = new Loader({
apiKey: process.env.NEXT_PUBLIC_API_KEY,
version: 'weekly',
});
let map;
loader.load().then(() => {
// Setting parameters for embedding Google Maps
const initialView = {
center: {
lat: 34.9988127,
lng: 135.7674863,
},
zoom: 14,
};
const buttonsDisabled = {
fullscreenControl: false,
mapTypeControl: false,
streetViewControl: false,
zoomControl: false,
};
// Embedding Google Maps
const google = window.google;
map = new google.maps.Map(googlemap.current, {
...initialView,
...buttonsDisabled,
});
setMapObject(map); // NOTE
});
}, [setMapObject]);
return <div ref={googlemap} />;
};
Map.propTypes = {
setMapObject: PropTypes.func.isRequired,
};
export default Map;

What’s important for this article is the line commented with "NOTE":

setMapObject(map);

This passes the embedded Google Maps as a JavaScript object up to the pages/index.js.

This way, the <LocatorButton> component can access to the embedded Google Maps as its mapObject prop:

// components/LocatorButton.jsimport PropTypes from 'prop-types';const LocatorButton = ({mapObject}) => {
return (
<button
type="button"
>
<!-- Insert the button label image -->
</button>
);
};
LocatorButton.propTypes = {
mapObject: PropTypes.object,
};
export default LocatorButton;

where I use PropTypes to define the type of the mapObject prop (see React documentation for detail on PropTypes).

Now we’re ready to mark the user’s current location on the embedded Google Maps.

Footnote: I use a state variable to pass mapObject from Map component to LocatorButton component. The use of a state variable, however, causes the re-rendering of the entire app once mapObject changes from its initial value of null to an instance of Google Maps. This is unnecessary re-rendering, because no part of the UI changes after the map is loaded. It's something I need to investigate in the future.

2. Snapping map to user location

Showing the user’s location on a map means two things: (1) marking the location on the map and (2) snapping the map to it. Let me first tackle the second “snapping” part, because it is relatively simple.

Let’s start by adding a click handler to the <button> element:

const LocatorButton = ({mapObject}) => {
const getUserLocation = () => { // ADDED
// To be defined below // ADDED
}; // ADDED
return (
<button
onClick={getUserLocation} // ADDED
type="button"
>
<!-- Insert the button label image -->
</button>
);
};

This is the standard way of adding an event hander in React (see React documentation).

Then we define the getUserLocation() function as follows.

First up, handle those legacy browsers that do not support Geolocation API, a web API that allows the browser to access to the location data in the user’s device. Following the suggestion by Kinlan (2019), I use the feature detection technique to handle those browsers:

const getUserLocation = () => {
if (navigator.geolocation) {
// code for showing the user's location
} else {
// code for legacy browsers
}
};

In Section 5.3 below, I’ll briefly discuss how to handle those legacy browsers.

Then, for those browsers that do support Geolocation API, I retrieve the user’s current location data from their device by calling the getCurrentPosition() method:

const getUserLocation = () => {
if (navigator.geolocation) {
// ADDED FROM HERE
navigator.geolocation.getCurrentPosition(position => {
// code for processing user location data
});
// ADDED UNTIL HERE
} else {
// code for legacy browsers
}
};

It’s a bit tricky to understand how the getCurrentPosition() method works. Here's my understanding (see MDN Web Docs for more proper explanation).

When it runs, it retrieves the user location data from their device. This is done asynchronously: it won’t prevent the rest of the code from running immediately after. Once the location data is obtained, it’s passed to a function specified as the argument for getCurrentPosition(). In the above code, this data is given the name of position. Taking position as an argument, this function will be executed.

The user location data takes the form of a JavaScript object formally called the GeolocationPosition interface, which has a property called coords. This coords property in turn stores the user's location coordinates as its own latitude and longitude properties.

So I store the coordinates of the user’s location as a JavaScript object called userLocation:

const getUserLocation = () => {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(position => {
const userLocation = { // ADDED
lat: position.coords.latitude, // ADDED
lng: position.coords.longitude, // ADDED
}; // ADDED
});
} else {
// code for legacy browsers
}
};

I use property names lat and lng because that's how Google Maps JavaScript API refers to the coordinates of locations (known as LatLng class).

Now we’re ready to use the setCenter() method from Google Maps JavaScript API to snap the map to the user's current location:

const getUserLocation = () => {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(position => {
const userLocation = {
lat: position.coords.latitude,
lng: position.coords.longitude,
};
mapObject.setCenter(userLocation); // ADDED
});
} else {
// code for legacy browsers
}
};

where mapObject, if you remember, refers to the embedded Google Maps, passed as a prop to the LocatorButton component (see Section 1 above if your memory slips).

3. Marking user’s current location

Now it’s time to mark the user’s location on the map.

3.1 Marker

As a marker, I imitate what Google Maps app does: a white-rimmed circle in Google’s brand blue:

A screenshot of Google Maps app in which the blue dot indicates the user’s current location (image source: Google Maps Help)

I’ve learned about how to render this particular type of the blue dot from the source code of Geolocation Marker:

const blueDot = {
fillColor: color['google-blue 100'],
fillOpacity: 1,
path: google.maps.SymbolPath.CIRCLE,
scale: 8,
strokeColor: color['white 100'],
strokeWeight: 2,
};

where I define the color object as design tokens in a separate file:

// designtokens.jsexport const color = {
'google-blue 100': `#4285F4`,
'white 100': `rgb(255,255,255)`,
}

I prefer this way of setting color because the color code itself doesn’t tell me anything about the reason behind the color choice. For example, the color code #4285F4 is the blue used in Google's logo (source: U.S. Brand Colors). So I call it google-blue 100 where 100 refers to the opacity of 1. (If I need to use semi-transparent Google Blue, I can then call it google-blue 50, for example.)

3.2 Adding marker to map

With Google Maps JavaScript API, we can add a marker to the map as follows. First, create a marker as a JavaScript object with the google.maps.Marker() method. Then, add the Marker object to the map with its own method setMap().

Sounds simple. But it actually isn’t, because I’m using React to build the app.

NOTE: If you only want to know the code that works, skip to the sub-section entitled “Fourth Attempt” below.

First Attempt

My first attempt didn’t work properly. I created a Marker object:

// Don't code like this
const marker = new google.maps.Marker({
icon: blueDot,
position: userLocation,
title: 'You are here!'
})

where the icon property refers to the marker icon (which I have defined as blueCircle), position to the coordinates of the user's current position (which I have defined as userLocation), and title to the text to be shown when the user hovers over the marker. (See Google Maps Platform documentation for all the options available for the Marker object.)

Then, I added the Marker object to the embedded map:

// Don't code like this
const marker = new google.maps.Marker({
icon: blueDot,
position: userLocation,
title: 'You are here!'
});
marker.setMap(mapObject); // ADDED

where the mapObject refers to the embedded Google Maps, passed as a prop to the LocatorButton component (as explained in Section 1 above).

This code caused a problem when the user taps the locator button again. In this situation, the above code adds a new marker at the current location without removing the marker at the previous location.

Which means we first need to remove the outdated marker before adding the updated one. To do so, we need to use the Marker object’s method setMap(null). Without running this, we would be adding more and more markers to the map.

Second Attempt

My second attempt was as follows (which turned out to be not desirable): I checked whether we have already created the Marker object. If so, I’d remove the marker from the map:

// Don't code like this
let marker;
if (marker) {
marker.setMap(null);
}

Then, I created a new marker tied to the user’s current position:

// Don't code like this
let marker;
if (marker) {
marker.setMap(null);
}
marker = new google.maps.Marker({ // REVISED
icon: blueDot,
position: userLocation,
title: 'You are here!'
});
marker.setMap(mapObject);

This code worked fine, but once I started using the useState() hook inside the <LocatorButton> component in order to change the UI in response to user actions (see Day 13 of this blog series), the previous marker wasn't removed when the user tapped the button for the second time.

Why? Because using the useState() hook causes the re-rendering of the <LocatorButton> component, which means the entire code gets re-run, including

let marker;

This means that every time the component gets re-rendered, the marker variable gets reset, losing the data on the previous user location. That's why the previous marker fails to be removed.

Third Attempt

My initial work around for this rerendering problem was to define marker outside the <LocatorButton> component (which worked, but turned out to be not the best practice for building a React app):

// This code works, but not the best practicelet marker; // REVISED
const LocatorButton = ({mapObject}) => {
...
if (marker) {
marker.setMap(null);
}
marker = new google.maps.Marker({
icon: blueDot,
position: userLocation,
title: 'You are here!'
});
marker.setMap(mapObject);
...
};

This way, the marker variable will be retained even when the <LocatorButton> component gets re-rendered. So the data on the user's previous location won't be lost, and the previous marker will get removed.

But then, while I was working for dealing with another issue (see Day 14 of this blog series), I learned about how to use the useRef() hook to retain the data across the re-rendering of React components.

Sounds like a solution for removing the previous marker on the user location!

Fourth Attempt

So I’ve revised the code as follows:

import {useRef} from 'react';    // ADDEDconst LocatorButton = ({mapObject}) => {
...
const marker = useRef(null); // ADDED
if (marker.current) { // REVISED
marker.current.setMap(null); // REVISED
}
marker.current = new google.maps.Marker({ // REVISED
icon: blueDot,
position: userLocation,
title: 'You are here!'
});
marker.current.setMap(mapObject); // REVISED
...
};

First, I define the marker variable by using the useRef hook. Then, I replace marker in the previous version of the code with marker.current. This is because the useRef hook creates an object whose current property will keep the value across the re-rendering of components (see React documentation for detail). It also makes the code more readable: we're now talking about the current value of marker at each run of the re-rendering, rather than marker which sounds like a constant value.

Now I ask myself: what’s the difference between useRef and defining a variable outside the component?

Googling this question immediately got me to Vash (2019), who explains the difference with an example code. In a nutshell, the difference emerges if I would use more than one <LocatorButton> component. By using useRef, each instance of the component keeps track of its own value. By defining a variable outside the component, however, all the instances of the component share the same value, which can lead to a weird situation as in this CodeSandbox example by Vash (2019).

For my case, it doesn’t matter as I won’t use more than one <LocatorButton> component, at least for now. But maybe I will. We never know. So it is safe to use useRef to keep track of data across re-rendering.

4. Showing location error range

The GPS functionality of devices cannot perfectly pinpoint the user’s location. To indicate the range of error on the map, I want to add a semi-transparent blue circle around the blue circle, as Google Maps app does:

A screenshot of Google Maps app in which the semi-transparent blue circle shows the range of error on the user’s current location (image source: Google Maps Help)

To do so, we first need to extract the GPS information on the range of error. The Geolocation API allows us to get this piece of information in the following way:

navigator.geolocation.getCurrentPosition(position => {
...
const errorRange = position.coords.accuracy; // ADDED
...
})

where position.coords.accuracy gives the radius in meters of a circle within which the user's current location falls 95 times out of 100 cases (source: MDN Web Docs).

To draw this circle, however, we cannot use the Marker object, which doesn’t allow us to set its size in meter. It took a while for me to figure out how to work around this limitation, but, again from the source code of Geolocation Marker, I’ve finally learned that the Circle object does the job (see Google Maps Platform documentation for detail).

The Circle object works in a similar fashion to the Marker object. So I first check if it’s already been added to the map. If so, remove it from the map:

const accuracyCircle = useRef(null);  // ADDED
...
navigator.geolocation.getCurrentPosition(position => {
...
const errorRange = position.coords.accuracy;
...
if (accuracyCircle.current) { // ADDED
accuracyCircle.current.setMap(null); // ADDED
} // ADDED

})

Then, define a new Circle object with the google.maps.Circle() method:

const accuracyCircle = useRef(null);
...
navigator.geolocation.getCurrentPosition(position => {
...
const errorRange = position.coords.accuracy;
...
if (accuracyCircle.current) {
accuracyCircle.current.setMap(null);
}
// ADDED FROM HERE
accuracyCircle.current = new google.maps.Circle({
center: userLocation,
fillColor: color['google-blue-dark 100'],
fillOpacity: 0.4,
radius: errorRange,
strokeColor: color['google-blue-light 100'],
strokeOpacity: 0.4,
strokeWeight: 1,
zIndex: 1,
});
// ADDED UNTIL HERE
})

where the center property refers to the center of the circle (which is set to be userLocation, the user's current location), and radius to the radius of the circle (which is set to be errorRange defined above). The zIndex property makes sure that the circle will be overlaid on the blue circle. The other properties define the appearance of the circle (see Google Maps Platform documentation for all the options available for Circle objects) where I define the colors as:

// designtokens.jsexport const color = {
'google-blue 100': `#4285F4`,
'google-blue-dark 100': `#61a0bf`, // ADDED
'google-blue-light 100': `#1bb6ff`, // ADDED
'white 100': `rgb(255,255,255)`,
}

These color codes are borrowed from the source code of Geolocation Marker. What’s nice about putting all the color codes together in one file is that we can immediately start reconsidering the change of the color palette. Maybe I want to redefine the light and dark variants of google-blue. If so, I can just look at this file, rather than searching through the entire codebase.

Finally, I add the circle to the map:

const accuracyCircle = useRef(null);
...
navigator.geolocation.getCurrentPosition(position => {
...
const errorRange = position.coords.accuracy;
...
if (accuracyCircle.current) {
accuracyCircle.current.setMap(null);
}
accuracyCircle.current = new google.maps.Circle({
center: userLocation,
fillColor: color['google-blue-dark 100'],
fillOpacity: 0.4,
radius: errorRange,
strokeColor: color['google-blue-light 100'],
strokeOpacity: 0.4,
strokeWeight: 1,
zIndex: 1,
});
accuracyCircle.current.setMap(mapObject); // ADDED
});

5. Improving UX

The code written so far does the basic job to tell the user where they are on the map. There are a few more things to do, however, for enhancing the user experiences.

5.1 Using cache up to one second

First, we can use the cached GPS information to make it faster to show the current location. I think 1 second is a reasonable amount of time to keep the cache. Humans walk about 1.4 meters per second (I cannot find the exact source for this data, but many say it’s about 1.4 meters per second). The range of location error with my iPhone SE (2nd Gen.) is about 12 meters. Using the location data one second ago, therefore, won’t terribly mislocate the user on the map.

To allow the Geolocation API to use the cached GPS information within the past one second, I add an optional parameter for getCurrentPosition():

navigator.geolocation.getCurrentPosition(position => {
// All the code descirbed in this article so far
}, {maximumAge: 1000} // ADDED
);

where the maximumAge option refers to the number of milliseconds to cache the location data (source: MDN Web Docs).

5.2 Flashing the button while waiting

Second, we need to tell the user that the app is working hard to locate where they are, while they are waiting for their location to be shown on the map after tapping the button. It can take a while. If there’s no UI change during this waiting time, the user may misunderstand that the app gets frozen or the button doesn’t function at all.

To tell the user that the app is working, we can make the trigger button keep flashing until the user’s location is shown on the map.

The implementation of this feature requires a long explanation, and it’s rather a different topic from the one in this article. So it’s described in Day 13 of this blog series:

5.3 Error handling

There are four possible errors when we use Geolocation API. When these errors occur, we should tell the user what happens, why it happens, and how they can deal with the error (Gregory 2021).

I’m still working on exactly how to show these error messages for the user. Making such a dialog in an accessible way is quite a bit of work (see Giraudel 2021). In this article, I only describe how to change the UI state to show error dialogs.

Geolocation API unsupported

First, the user’s browser may not support Geolocation API. This is unlikely to happen in 2021: the browsers supporting Geolocation API account for 96.78% of global page views in September 2021 (Can I Use 2021). But just in case.

I set the status variable to be geolocationDenied in this case:

const getUserLocation = () => {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(position => {
...
}, {maximumAge: 1000});
} else {
setStatus('geolocationDenied'); // ADDED
}
};

And then show a dialog explaining what happens if status takes the value of geolocationDenied.

Location service permission denied

Second, the user may have disabled location services with their browser/OS. This happens either immediately after pressing the button (because the user has turned off the location services before) or after the user is asked for permission upon the button click and responds with no.

This error is likely to happen because not an ignorable number of people are concerned about privacy on the web (e.g., Newman 2020).

If Geolocation API is unable to retrieve user location data because of the disabled location services, the getCurrentPosition() method returns the error code equal to 1 (source: MDN Web Docs). So we can create an error-handling function and specify it as the optional argument for getCurrentPosition():

  const getUserLocation = () => {
...
// ADDED FROM HERE
const handleGeolocationError(error, setStatus) {
if (error.code === 1) {
setStatus('permissionDenied');
}
};
// ADDED UNTIL HERE
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(position => {
...
}, error => { // REVISED
handleGeolocationError(error, setStatus); // REVISED

}, {maximumAge: 1000});
} else {
setStatus('geolocationDenied')
}
};

When the Geolocation API error code is 1, then we set the value of status to be permissionDenied. We can then render a dialog explaining what happens to the user.

Geolocation API failure

Third, the Geolocation API may fail to obtain the user’s location data from their device for an unknown reason. It’s not clear to me when this can happen. But in this case, the Geolocation API error code is 2. So we can revise the handleGeolocationError function as follows:

    const handleGeolocationError(error, setStatus) {
if (error.code === 1) {
setStatus('permissionDenied');
} else if (error.code === 2) { // ADDED
setStatus('positionUnavailable'); // ADDED

}
};

Render the corresponding dialog if the status takes the value of positionUnavailable.

Geolocation API not responding

Finally, there may be a situation where Geolocation API cannot obtain user location data for a long period of time. If this happens, with the current setting, the user cannot tell whether the app is functioning or not.

We should tell the user what is going on. Kinlan (2019) recommends setting a timeout of 10 seconds after which the user gets notified that it took more than 10 seconds to retrieve the location data. To implement this feature, we first need to add timeout as an additional optional parameter of the getCurrentPosition() method:

    navigator.geolocation.getCurrentPosition(position => {
...
}, error => {
handleGeolocationError(error, setStatus);
}, {maximumAge: 1000, timeout: 10000} // REVISED
);

This will make Geolocation API return the error code of 3 if there is no response after 10,000 milliseconds (i.e., 10 seconds). So I can revise the handleGeolocationError() as follows:

const handleGeolocationError(error, setStatus) {
if (error.code === 1) {
setStatus('permissionDenied');
} else if (error.code === 2) {
setStatus('positionUnavailable');
} else if (error.code === 3) { // ADDED
setStatus('timeout'); // ADDED
}
};

Then render the corresponding dialog when status takes the value of timeout.

Demo

With the code explained in this article (and Day 13 of this blog series for flashing the button), I’ve uploaded a demo app to Cloudflare Pages. Try to click the button. When asked for permission to use location services, answer both yes and no, to see how the UI changes.

If you notice something weird, file a bug report by posting a comment to this article. I’ll apprecaite your help to improve My Ideal Map App! ;-)

Next step

If My Ideal Map App were a desktop app, it would be good enough to show the user’s location each time the user clicks the button. However, the app is also meant to be used with a smartphone while the user is moving around in a city. It’s more desirable for the app to keep track of the user location, updating the marker constantly. Next step is to implement such a feature.

References

Can I Use (2021) “Geolocation API”, Can I Use?, accessed on Oct 25, 2021.

Giraudel, Kitty (2021) “Creating An Accessible Dialog From Scratch”, Smashing Magazine, Jul 28, 2021.

Gregory, Sonia (2021) “Best Error Messages: 5 Tips For A User-Friendly Experience”, FreshSparks, Sep 26, 2021 (last updated).

Kinlan, Paul (2019) “User Location”, Web Fundamentals, Feb 12, 2019.

Kudamatsu, Masa (2021) “4 Gotchas of embedding Google Maps with Next.js”, Dev.to, Feb 12, 2021.

Newman, Jared (2020) “Apple and Google’s tough new location privacy controls are working”, FastCompany, Jan 23, 2020.

Vash, Dennis (2019) “useRef will assign a reference for each component, while a variable defined outside a function component scope will only assign once...”, Stack Overflow, Aug 10, 2019.

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store