TL;DR
To create a web app that shows the user's location on embedded Google Maps with React:
- Create a state variable that stores an instance of Google Maps, and pass this state to a button component as its prop (Section 1).
- 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). - To mark the user's location on the map, use
google.maps.Marker()
method of Google Maps JavaScript API (Section 3). - 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). - 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 marker 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.js
import 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.js
import {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.js
import PropTypes from 'prop-types';
const LocatorButton = ({mapObject}) => {
return (
<button
type="button"
>
<!-- Insert the button label image -->
</button>
);
};
LocatorButton.propTypes = {
mapObject: PropTypes.object.isRequired,
};
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.js
export 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 practice
let 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'; // ADDED
const 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.js
export 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:
Day 13: Flashing tapped button while user is waiting (with React and Styled Components)
Masa Kudamatsu ・ Oct 26 '21
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) {
setStatus('timeout');
}
};
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.
Changelog
v1.0.1 (Nov 15, 2023): Correct typo.
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.
Top comments (0)