TL;DR
To create a user experience like this:
your React app needs to have a component coded as follows (warning: it is very long):
import {useContext, useRef, useState} from 'react';
// for using Google Maps's place ID set by another React component in the app (Section 2.2)
import {PlaceIdContext} from './PlaceIdContext';
// for managing UI states (Section 4.1)
import {useStateObject} from './useStateObject';
// for handling error responses (Section 2.4)
import FocusLock from 'react-focus-lock';
export const SearchedPlace = ({mapObject}) => {
// Manage UI changes (Sections 2.2 and 4.1)
const [state, setState] = useStateObject({
status: 'closed',
placeData: null,
});
const {status, placeData} = state;
// Receive Google Maps's place ID from another React component in the app (Section 2.2)
const [placeId, setPlaceId] = useContext(PlaceIdContext);
// Prepare for dropping a marker to the searched place location (Section 3.3)
const marker = useRef();
// Make an API call after the component gets rendered (Section 2.2)
useEffect(() => {
if (!placeId) return;
setState({status: 'loading'});
// Remove the marker from Google Maps for the previously searched place (Section 3.3)
if (marker.current) {
marker.current.setMap(null);
}
// Fetch place detail from Google Maps server (Section 2.1)
const google = window.google;
const service = new google.maps.places.PlacesService(mapObject);
const request = {
placeId: placeId,
fields: [
'formatted_address',
'geometry',
'name',
'url',
],
};
service.getDetails(request, handleResponse);
function handleResponse(place, placesServiceStatus) {
// Handle successful responses (Section 2.3)
if (placesServiceStatus === 'OK') {
const searchedPlace = {
address: place.formatted_address,
coordinates: {
lat: place.geometry.location.lat(),
lng: place.geometry.location.lng(),
},
name: place.name,
url: place.url,
};
// Customize the place marker with an SVG icon(Section 3.4)
const searchedPlaceMarker = {
filePath: '/searched-place-mark.svg',
height: 37.876,
width: 39.644,
}
marker.current = new google.maps.Marker({
icon: {
url: searchedPlaceMarker.filePath,
anchor: new google.maps.Point(
searchedPlaceMarker.width / 2,
searchedPlaceMarker.height / 2,
),
},
// For reopening the place detail popup by cliking the place mark (Section 4.4)
optimized: false,
// Prepare to drop the marker to the searched place location (Section 3.1)
position: searchedPlace.coordinates,
title: searchedPlace.name,
});
// Allow the user to open the place detail popup by clicking the place mark (Section 4.4)
marker.current.addListener('click', () => {
setState({status: 'open'});
});
// Drop the marker to the searched place location (Section 3.1)
marker.current.setMap(mapObject);
// Snap the map to the area around the searched place (Section 3.2)
mapObject.panTo(searchedPlace.coordinates);
// Open the popup window for the detail of the searched place (Section 4.2)
setState({
status: 'open',
placeData: searchedPlace
});
// Handle error responses (Section 2.4)
} else {
console.error('Google Maps Place Details API call has failed.');
setState({status: 'error'});
}
}
}, [mapObject, placeId]);
// For closing the place detail popup (Section 4.3)
const closePlaceInfo = () => {
setState({
status: 'closed',
});
};
// Close the popup by pressing the outside of it (Section 4.3)
const dialogDiv = useRef(null);
useEffect(() => {
const listener = event => {
if (!dialogDiv.current || dialogDiv.current.contains(event.target)) {
return;
}
closePlaceInfo();
};
document.addEventListener('pointerdown', listener);
return () => {
document.removeEventListener('pointerdown', listener);
};
}, [closePlaceInfo, dialogDiv]);
// Close the popup by pressing the ESC key (Section 4.3)
useEffect(() => {
const closeByEsc = event => {
if (event.key === 'Escape') {
closePlaceInfo();
}
};
if (status === 'open') {
document.addEventListener('keydown', closeByEsc);
} else {
document.removeEventListener('keydown', closeByEsc);
}
return () => {
document.removeEventListener('keydown', closeByEsc);
};
}, [closePlaceInfo, status]);
// Render no HTML element by default (Section 2.2)
if status === 'closed' {
return null;
// Render a loading message while making an API request (Section 2.2)
} else if (status === 'loading') {
return (
<div>
<p aria-live="polite" role="status">Getting more information about this place...</p>
</div>
)
// Handle error responses (Section 2.4)
} else if (status === 'error') {
return (
<FocusLock>
<div role="alertdialog" aria-describedby="error-message" aria-labelledby="error-title">
<h2 id="error-title">
Unable to get place detail
</h2>
<p id="error-message">
Google Maps server is currently down. <a
href="https://status.cloud.google.com/maps-platform/products/i3CZYPyLB1zevsm2AV6M/history"
rel="noreferrer"
target="_blank"
>
Please check its status
</a>, and try again once they fix the problem (usually within a few hours).
</p>
<button
data-autofocus
onClick={() => setStatus('closed')}
type="button"
>
Got It
</button>
</div>
</FocusLock>
)
// Show the detail of the searched place in a popup window (Section 4.2)
} else if (status === 'open') {
return (
<FocusLock>
<div
aria-label={placeData.name}
ref={dialogDiv} // For closing the popup by pressing the outside of it (Section 4.3)
role="dialog"
>
<h2>{placeData.name}</h2>
<p>{placeData.address}</p>
<button
data-autofocus
onClick={
/* Click handler to save the searched place
into the user's database in the server
(to be specified in a future post of this blog series)
*/
}
type="button"
>
Save
</button>
<a href={placeData.url} rel="noreferrer" target="_blank">More Info</a>
{/* Close the popup by pressing the close button (Section 4.3) */}
<button aria-label="Close the place detail" onClick={closePlaceInfo} type="button">
{/* Insert the SVG code for close button icon */}
</button>
</div>
</FocusLock>
)
}
}
where (1) the useStateObject
custom hook is defined as
// ./useStateObject.js
// See Section 4.1
import {useReducer} from 'react';
const reducer = (state, action) => ({...state, ...action});
export const useStateObject = initialState => {
const [state, setState] = useReducer(reducer, initialState);
return [state, setState];
};
(2) the mapObject
prop is passed from a parent component in which it is defined as
let mapObject;
mapObject = new google.maps.Map(document.getElementById("map"), {
center: { lat: -34.397, lng: 150.644 }, // or any other location
zoom: 8, // or any other zoom level
});
(3) PlaceIdContext
is given by
// PlaceIdContext.js
// See Section 2.2
import {createContext, useState} from 'react';
export const PlaceIdContext = createContext();
export function PlaceIdProvider({...props}) {
const [placeId, setPlaceId] = useState('');
const value = [placeId, setPlaceId];
return <PlaceIdContext.Provider value={value} {...props} />;
}
and (4) the placeId
value is set with setPlaceId()
in another component in charge of Google Maps autocomplete search (which triggers the re-rendering of the <SearchedPlace>
component defined above).
1. Introduction
As far as I know, no one has written online how to integrate Google Maps search into a React app. This article fills this gap in the web dev online community.
The first half of Google Maps search, that is, displaying autocomplete suggestions in response to the user’s query, has already been discussed in Day 25 of this blog series.
The present article focuses the second half of it: what happens after the user selects one of the autocomplete search suggestions. It consists of three parts:
- Call Google Maps API to retrieve the information of the place the user has selected (Section 2);
- Drop a pin onto the map (Section 3);
- Show the information of the searched place (Section 4).
If you are interested only in one of these three topics, skip to the relevant section of this article.
Why am I entitled to write this article? Because I’ve been developing a React app called My Ideal Map, which embeds Google Maps and allows the user to search for a place. The code written in this article is what I have discovered as a way to achieve the three above-mentioned aspects of user experience.
2. Making an API call to Google Maps server
Let’s first review a vanilla JS version of the code to fetch the data on a place from the Google Maps server (Section 2.1). Then, we adapt the code to React (Section 2.2). Finally, we discuss how to handle successful responses (Section 2.3) and error responses (Section 2.4).
2.1 Vanilla JavaScript version
We need the following code snippet to send a network request to the Googel Maps server for fetching the detail of a place whose Google Maps ID is provided as placeId
:
const google = window.google;
const service = new google.maps.places.PlacesService(mapObject);
const request = {
placeId: placeId,
fields: [
'formatted_address',
'geometry',
'name',
'url',
],
};
service.getDetails(request, handleResponse);
function handleResponse(place, placesServiceStatus) {
// to be completed in the rest of this article
}
which is adapted from the code shown in the Google Maps Platform documentation.
Let’s go through it line by line.
(1) Setting up
const google = window.google;
This line simply clarifies that we use a global object called google
. By doing so, I follow the recommendation by Abramov (2017). If you don’t like it, you can do away with it.
const service = new google.maps.places.PlacesService(mapObject);
Here we set up the Places service of Google Maps API so that we can use its method called getDetails()
to make an API call. Its argument, called mapObject
here, refers to the object instantiated to embed Google Maps in our web app with the code like this:
mapObject = new google.maps.Map(document.getElementById("map"), {
center: { lat: -34.397, lng: 150.644 }, // or any other location
zoom: 8, // or any other zoom level
});
(For how to adapt this code to React, see Section 2 of Kudamatsu (2021), one of the popular articles I have written).
Consequently, we need to pass the mapObject
value from the React component that renders a map. This topic was discussed in Section 1 of Day 12 of this blog series, where the mapObject
was shared with the button to track the user’s location with Geolocation API. So I skip explaining how.
(2) Specifying which information to fetch
In the next set of lines:
const request = {
placeId: placeId,
fields: [
'formatted_address',
'geometry',
'name',
'url',
],
};
I first specify placeId
, the Google Maps’s identifier for the place selected by the user. This value needs to be passed from a React component that renders autocomplete search (to be discussed in Section 2.2 below).
Then, for the fields
property, I specify which information on the place to be fetched. Here I go for
- The place’s name (
name
), - Its longitude and latitude (
geometry
) to drop a pin on the map, - Its street address (
formatted_address
) to make sure it is not a place of the same name in a different city, and - Its URL on Google Maps app (
url
) to allow the user to see more information on the place if they wish.
There are many other pieces of information on places up for grab from the Google Maps server. For the alphabetical list, see the Google Maps Platform documentation. Beware that some fields (categorised as “Contact Data” or “Atmosphere Data” incur additional charges (US$3 or $5 per 1,000 requests, respectively) to retrieve.
(3) Making an API call
The following line of code will send a request to the Google Maps server:
service.getDetails(request, handleResponse);
where the first argument, request
, refers to the object we have just defined.
The second argument, handleResponse
, refers to a callback function that will be executed once the response is received from the Google Maps server.
The callback function can be defined before this line of code. For code readability, however, I prefer defining it immediately after, by using the coding technique known as hoisting:
service.getDetails(request, handleResponse);
function handleResponse(place, placesServiceStatus) {
// to be completed
}
where the two arguments, place
and placesServiceStatus
, refer to the JSON object for the detailed information of the place and the string value that indicates whether the request is made with success or with an error, respectively.
2.2 Adapting to React
Now we know how to make an API call to the Google Maps server. The challenge is how to execute it with React.
(1) Handling Place ID with React Context
One challenge is how to pass the value for placeId
from another React component.
The placeId
value is initially an empty string when the app is launched. Whenever the user chooses one of the autocomplete search suggestions in response to their query, its value changes to Google Maps’s place ID for the place chosen by the user.
To avoid prop drilling (see Dodds (2018) for what it is about), I use React Context to keep track of the placeId
value.
First, create a context provider component:
// PlaceIdContext.js
import {createContext, useState} from 'react';
export const PlaceIdContext = createContext();
export function PlaceIdProvider({...props}) {
const [placeId, setPlaceId] = useState('');
const value = [placeId, setPlaceId];
return <PlaceIdContext.Provider value={value} {...props} />;
}
which follows the practice recommended by Dodds (2021).
Then, wrap the entire app with <PlaceIdProvider>
so that any React component in the app can consume placeId
and/or setPlaceId
via the useContext
hook in the following way:
import {useContext} from 'react';
import {PlaceIdContext} from './PlaceIdContext';
export const AnyReactComponent = () => {
const [placeId, setPlaceId] = useContext(PlaceIdContext);
}
(2) SearchBox component
Another challenge is how to trigger the running of the code for fetching data from the Google Maps server when the user clicks one of the autocomplete search suggestions.
An autocomplete search experience in the app is provided by a React component called SearchBox
, as described in Day 25 of this blog series. Section 5.3 of that article explains how each of the autocomplete suggestions was rendered as an <li>
element:
<li
key={item.id}
{...getItemProps({
item,
index
})}
>
where item.id
refers the place ID that we have been talking about: the identifer to be used to retrieve more information on the place from the Google Maps server. And the ...getItemProps({item, index})
, a prop getter method from the Downshift library, adds all the attributes necessary for accessibility to the <li>
element.
Now we add a click event handler to the <li>
element so that the place ID will be used as a new value of the placeId
state:
<li
key={item.id}
{...getItemProps({
item,
index,
onClick: event => { // ADDED
setPlaceId(item.id); // ADDED
} // ADDED
})}
>
where setPlaceId()
is imported via the useContext
hook as described above.
This way, whenever the user clicks one of the autocomplete search suggestions, the placeId
value gets updated, triggering the re-rendering of a component that uses placeId
, that is, the component that makes a request to the Google Maps server. Which is what we are now going to code.
(3) Make an API call with useEffect
hook
The final challenge is how to send a network request to the Google Maps server once the component gets re-rendered. The short answer is the useEffect
hook (see React docs) with placeId
as its dependency.
Let’s create a new React component called SearchedPlace
which is in charge of rendering the place the user has selected among autocomplete search results:
import {useState} from 'react';
export const SearchedPlace = ({mapObject}) => {
const [status, setStatus] = useState('closed');
if status === 'closed' {
return null;
}
}
Its UI state is by default 'closed'
, in which case no HTML element is rendered. Its prop mapObject
refers to the incidence of an embedded Google Maps as described in Section 2.1 above.
Now let’s add the code to make an API call:
import {useContext, useState} from 'react'; // REVISED
import {PlaceIdContext} from './PlaceIdContext'; // ADDED
export const SearchedPlace = ({mapObject}) => {
const [status, setStatus] = useState('closed');
// ADDED FROM HERE
const [placeId, setPlaceId] = useContext(PlaceIdContext);
useEffect(() => {
const google = window.google;
const service = new google.maps.places.PlacesService(mapObject);
const request = {
placeId: placeId,
fields: [
'formatted_address',
'geometry',
'name',
'url',
],
};
service.getDetails(request, handleResponse);
function handleResponse(place, placesServiceStatus) {
// To be completed in the rest of this article
}
}, [mapObject, placeId]);
// ADDED UNTIL HERE
if status === 'closed' {
return null;
}
}
We need to wrap the entire code for making an API call inside the useEffect
hook, which runs after the component gets rendered. This is because the google
object does not exist until the component gets rendered.
However, the above code returns an error when placeId
is an empty string. In such a case, we should skip running the code:
...
useEffect(() => {
if (!placeId) return; // ADDED
const google = window.google;
...
(4) Loading message
Also, the network connection can be slow. To tell the user that the app is in the process of retrieving information from the Google Maps server, let’s update the UI once the useEffect
hook starts running:
import {useContext, useState} from 'react';
import {PlaceIdContext} from './PlaceIdContext';
export const SearchedPlace = ({mapObject}) => {
const [status, setStatus] = useState('closed');
const [placeId, setPlaceId] = useContext(PlaceIdContext);
useEffect(() => {
if (!placeId) return;
setStatus('loading'); // ADDED
const google = window.google;
const service = new google.maps.places.PlacesService(mapObject);
// This part of the code is omitted for brevity
}, [mapObject, placeId]);
if status === 'closed' {
return null;
// ADDED FROM HERE
} else if (status === 'loading') {
return (
<div>
<p aria-live="polite" role="status">Getting more information about this place...</p>
</div>
)
// ADDED UNTIL HERE
}
}
I cannot find the definitive information on how to make loading messages accessible. But Bischoff (2016) suggests adding aria-live="polite"
(and optionally role="status"
) to the element that contains the loading message text. I’ve tested this solution with VoiceOver, Mac OS’s screen reader. The loading message does get announced if it takes time to load, and the announcement gets interrupted as soon as the searched place information popup becomes visible.
However, Golcic (2020) argues (1) role="status"
lacks cross-browser consistency in its implementation and (2) the element with aria-live
has to be in the DOM on page load, rather than being added by JavaScript.
Let me know what you think if you are an accessibility expert.
By the way, the wrapping <div>
is redundant, but it’s likely to be useful to style the box that surrounds the loading message text.
Now let’s move on to handle responses from the Google Maps server.
2.3 Handling successful responses
The Google Maps server returns two variables if the network request is successful. These two variables can be named in any way, but let’s call them placesServiceStatus
and place
.
(1) placesServiceStatus
variable
This variable equals to OK
if the network request has been successful. (For other values, see the Google Maps Platform documentation.)
To handle successful and error cases separately, therefore, we can code the callback function for the getDetails()
method as follows:
...
service.getDetails(request, handleResponse);
function handleResponse(place, placesServiceStatus) {
// ADDED FROM HERE
if (placesServiceStatus === 'OK') {
// to be completed
} else {
console.error('Google Maps Place Details API call has failed.');
}
// ADDED UNTIL HERE
}
...
(2) place
JSON object
In case of the successful request, the Google Maps server also returns the place
JSON object, which in our case is something like this:
{
"formatted_address" : "3-16 Sagatenryūji Susukinobabachō, Ukyo Ward, Kyoto, 616-8385, Japan",
"geometry" : {
"location" : {
"lat" : 35.013867,
"lng" : 135.6762773
},
"viewport" : {
"northeast" : {
"lat" : 35.0151522302915,
"lng" : 135.6777188302915
},
"southwest" : {
"lat" : 35.0124542697085,
"lng" : 135.6750208697085
}
}
},
"name" : "Fukuda Art Museum",
"url" : "https://maps.google.com/?cid=540503174389752899"
},
Note that the top-level properties correspond to the fields that we have specified in the getDetails() method:
...
const request = {
placeId: placeId,
fields: [
'formatted_address',
'geometry',
'name',
'url',
],
};
service.getDetails(request, handleResponse);
...
By specifying the callback function further, we can process this response to be used for dropping a pin on the map (see Section 3 below) and displaying the place information (see Section 4 below):
...
service.getDetails(request, handleResponse);
function handleResponse(place, placesServiceStatus) {
if (placesServiceStatus === 'OK') {
// ADDED FROM HERE
const searchedPlace = {
address: place.formatted_address,
coordinates: {
lat: place.geometry.location.lat(),
lng: place.geometry.location.lng(),
},
name: place.name,
url: place.url,
};
// ADDED UNTIL HERE
} else {
console.error('Google Maps Place Details API call has failed.');
}
}
...
2.4 Handling error responses
In the unlikely event of the Google Maps server returning an error response, we need to render an error message so that the user can learn what is going on.
First, let’s update the status
variable to "error"
if placesServiceStatus
is not 'OK'
:
...
service.getDetails(request, handleResponse);
function handleResponse(place, placesServiceStatus) {
if (placesServiceStatus === 'OK') {
const searchedPlace = {
address: place.formatted_address,
coordinates: {
lat: place.geometry.location.lat(),
lng: place.geometry.location.lng(),
},
name: place.name,
url: place.url,
};
} else {
console.error('Google Maps Place Details API call has failed.');
setStatus('error'); // ADDED
}
}
...
Then render an alertdialog
div that shows an error message:
...
if status === 'closed' {
return null;
} else if (status === 'loading') {
return (
<div>
<p aria-live="polite" role="status">Getting more information about this place...</p>
</div>
)
// ADDED FROM HERE
} else if (status === 'error') {
return (
<div role="alertdialog" aria-describedby="error-message" aria-labelledby="error-title">
<h2 id="error-title">
Unable to get place detail
</h2>
<p id="error-message">
Google Maps server is currently down. <a
href="https://status.cloud.google.com/maps-platform/products/i3CZYPyLB1zevsm2AV6M/history"
rel="noreferrer"
target="_blank"
>
Please check its status
</a>, and try again once they fix the problem (usually within a few hours).
</p>
<button
onClick={() => setStatus('closed')}
type="button"
>
Got It
</button>
</div>
)
// ADDED UNTIL HERE
}
...
However, the above code is not enough for an HTML element with role="alertdialog"
to announce its text content via the aria-labelledby
and aria-describedby
attributes. When it is opened, the alertdialog
element needs to have its child interactive element auto-focused.
The above code is also insufficient because the error dialog needs to be modal, that is, the user must not be able to move the focus to other interactive elements outside the dialog.
To achieve this set of focus management for modal dialogs, my preferred solution is to use the react-focus-lock
library:
import FocusLock from 'react-focus-lock'; // ADDED
...
...
} else if (status === 'error') {
return (
<FocusLock> {/* ADDED */}
<div role="alertdialog" aria-describedby="error-message" aria-labelledby="error-title">
<h2 id="error-title">
Unable to get place detail
</h2>
<p id="error-message">
Google Maps server is currently down. <a
href="https://status.cloud.google.com/maps-platform/products/i3CZYPyLB1zevsm2AV6M/history"
rel="noreferrer"
target="_blank"
>
Please check its status
</a>, and try again once they fix the problem (usually within a few hours).
</p>
<button
data-autofocus // ADDED
onClick={() => setStatus('closed')}
type="button"
>
Got It
</button>
</div>
</FocusLock> {/* ADDED */}
)
// ADDED UNTIL HERE
}
...
The entire alertdialog
element is wrapped with the <FocusLock>
component, to trap the focus within it. Plus, the data-autofocus
attribute is added to the button
element so that, thanks to the react-focus-lock
library, the button will be auto-focused when the alertdialog
element is rendered. Very simple, isn’t it? (The inert
attribute is the future way to trap the focus (see Friedman 2022), but its browser support is slightly below 90% of global page views in June 2023 according to Can I Use?. So I would rather wait for a bit.)
Final note: the error message itself can surely be improved. In particular, it can change its text depending on the placesServiceStatus
value. This is a topic that deserves one single article which I will write in the future.
3. Dropping a place mark on the map
Now we want to drop a marker on the embedded Google Maps to indicate the location of the place selected by the user among autocomplete search suggestions.
For this purpose, we need the following two pieces of data fetched from the Google Maps server with the code described in the previous section (where unnecessary data are temporarily commented out):
service.getDetails(request, handleResponse);
function handleResponse(place, placesServiceStatus) {
if (placesServiceStatus === 'OK') {
const searchedPlace = {
// address: place.formatted_address,
coordinates: {
lat: place.geometry.location.lat(),
lng: place.geometry.location.lng(),
},
name: place.name,
// url: place.url,
};
} else {
console.error('Google Maps Place Details API call has failed.');
setStatus('error');
}
}
We therefore refer to the place’s name as searchedPlace.name
and its location as searchedPlace.coordinates
in this section. First we set the location to which the place mark is dropped (Section 3.1). Then we snap the map to the area round it (Section 3.2). Third, we handle the removal of the mark on the previously searched place (Section 3.3), which turns out to be a bit tricky with React. Finally, we customize the place mark with an SVG image (Section 3.4).
3.1 Specify the location
To drop a marker on embedded Google Maps, we use the following code snippet (see Google Maps Platform documentation for detail):
const marker = new google.maps.Marker({
position: searchedPlace.coordinates,
title: "searchedPlace.name,"
});
marker.setMap(mapObject);
where position
sets the latitude and longitude of the place marker with an object whose properties are lat
and lng
, and title
sets the text to pop up as a tooltip when the user hovers over the place mark.
Once the marker
object is created, then we can execute its method setMap(mapObject)
to render the place mark on the map, where mapObject
refers to the instance of the embedded Google Maps (see Section 2.1 above).
3.2 Snap the map to the searched place
In addition to dropping a place mark onto the searched place, we need to snap the map to that location; otherwise the user would not be able to see the dropped marker.
To do so, we use the panTo()
method of mapObject
:
const marker = new google.maps.Marker({
position: searchedPlace.coordinates,
title: "searchedPlace.name,"
});
marker.setMap(mapObject);
mapObject.panTo(searchedPlace.coordinates); // ADDED
With this code, the map will show the searched place at the center of the screen.
There is another method called setCenter()
, which does the same job. But panTo()
animates the movement of the map more smoothly than setCenter()
does (danhardman 2014).
3.3 Remove the marker for the previously searched place
If the user searches for a place only once, the above code works just fine. In reality, the app should allow the user to repeat their search. The code written so far does not erase the marker for the previously searched places.
With React, the removal of place markers requires the useRef
hook, as explained in extensive detail in Section 3.2 of Day 12 of this blog post series.
The idea is that, instead of creating a constant variable marker
inside the useEffect
hook, generate a marker
variable outside the useEffect
hook with useRef
. Then assign the instance of a marker object to marker.current
each time the useEffect
hook is run. If marker.current
is already assigned, remove it from the map with marker.current.setMap(null)
:
import {useContext, useRef, useState} from 'react'; // REVISED
import {PlaceIdContext} from './PlaceIdContext';
export const SearchedPlace = ({mapObject}) => {
const [status, setStatus] = useState('closed');
const [placeId, setPlaceId] = useContext(PlaceIdContext);
const marker = useRef(); // ADDED
useEffect(() => {
if (!placeId) return;
setStatus('loading');
if (marker.current) { // ADDED
marker.current.setMap(null); // ADDED
} // ADDED
const google = window.google;
const service = new google.maps.places.PlacesService(mapObject);
const request = {
placeId: placeId,
fields: [
'formatted_address',
'geometry',
'name',
'url',
],
};
service.getDetails(request, handleResponse);
function handleResponse(place, placesServiceStatus) {
if (placesServiceStatus === 'OK') {
const searchedPlace = {
address: place.formatted_address,
coordinates: {
lat: place.geometry.location.lat(),
lng: place.geometry.location.lng(),
},
name: place.name,
url: place.url,
};
// REVISED FROM HERE
marker.current = new google.maps.Marker({
position: searchedPlace.coordinates,
title: "searchedPlace.name,"
});
marker.current.setMap(mapObject);
// REVISED UNTIL HERE
mapObject.panTo(searchedPlace.coordinates);
} else {
console.error('Google Maps Place Details API call has failed.');
setStatus('error');
}
}
}, [mapObject, placeId]);
// the rest of the code omitted for brevity
}
The reason behind this way of coding is that each time the SearchedPlace
component gets re-rendered, the marker
variable would otherwise be re-defined, preventing us from accessing the previous marker object to remove it from the map. The useRef
will retain the value even when the component gets re-rendered.
3.4 Customize the marker
Finally, we customize the appearance of the place maker by using an SVG image. To do so, add icon
property to the marker
object, which needs to refer to an object with url
property:
marker.current = new google.maps.Marker({
// ADDED FROM HERE
icon: {
url: '/searched-place-mark.svg',
},
// ADDED UNTIL HERE
position: searchedPlace.coordinates,
title: "searchedPlace.name,"
});
marker.current.setMap(mapObject);
where we assume that the SVG image file, searched-place-mark.svg
, is saved in the root directory of the website.
This is not enough, however. By default, Google Maps anchors an SVG image at its top-left corner. This means that, when the user zooms the map in and out, the place mark appears to move around over the map.
To fix this behavior, the SVG image needs to be anchored at its center. The SVG image contains its size information in its source code: the height
and width
attributes of the <svg>
element. So we can copy and paste the height
and width
attribute values:
const searchedPlaceMarker = {
height: 37.876,
width: 39.644,
}
Then use these values to anchor the SVG image at its center:
icon: {
url: '/searched-place-mark.svg',
// ADDED FROM HERE
anchor: new google.maps.Point(
searchedPlaceMarker.width / 2,
searchedPlaceMarker.height / 2,
),
// ADDED UNTIL HERE
},
where new google.maps.Point()
is Google Maps's API function to specify a coordinate.
For more options for the icon
property, see Google Maps Platform documentation.
The above code works, but to make the code more readable, let’s define the file path as the filePath
property of the searchedPlaceMarker
object:
const searchedPlaceMarker = {
filePath: '/searched-place-mark.svg', // ADDED
height: 37.876,
width: 39.644,
}
marker.current = new google.maps.Marker({
icon: {
url: searchedPlaceMarker.filePath, // REVISED
anchor: new google.maps.Point(
searchedPlaceMarker.width / 2,
searchedPlaceMarker.height / 2,
),
},
position: searchedPlace.coordinates,
title: "searchedPlace.name,"
});
marker.current.setMap(mapObject);
mapObject.panTo(searchedPlace.coordinates);
This way, in case we need to change the file path or replace the SVG image with another, we just need to revise the code for the searchedPlaceMarker
object, with the implementation code intact.
4. Showing the place detail
Now we want to show the information of the place the user has selected among Google Maps autocomplete search suggestions.
We first discuss how to manage React states to update the UI (Section 4.1). Then we render the information of the searched place in a popup window (Section 4.2). Third, we allow the user to close the popup (Section 4.3). Finally, we also allow the user to reopen the popup by pressing the place mark on the embedded Google Maps (Section 4.4).
4.1 React state for place data
Since it is a change in UI to show the information of the place searched by the user, we need to re-render the React component to reflect the UI change. To do so, we need to update a React state for this component.
But re-rendering will lose the data fetched from the Google Maps server. One way to go about it is to add another state to hold the data:
...
export const SearchedPlace = ({mapObject}) => {
...
const [status, setStatus] = useState('closed');
const [placeData, setPlaceData] = useState(null); // ADDED
...
But whenever the placeData
gets updated, we also want to update status
so the place information will get revealed. So it’s best to group them together as an object:
...
export const SearchedPlace = ({mapObject}) => {
...
const [state, setState] = useState({
status: 'closed',
placeData: null,
});
const {status, placeData} = state;
...
The second line destructures the state
object so that our code written so far will not break due to the use of status
. (This is a technique I learned from Epic React’s “React Hooks” module.)
However, with the useState
hook, we always need to specify both property values whenever we update the state
variable, like this:
setState({
status: 'loading',
placeData: null,
})
which requires an extra redundant line of code. Plus, in case I mistakenly write the code as follows
setState({
status: 'loading',
})
the placeData
property is gone, throwing an error where placeData
is used.
My workaround is to create a custom hook that I call useStateObject
, with the help of the useReducer
hook (the generalized version of the useState
hook):
// ./useStateObject.js
import {useReducer} from 'react';
const reducer = (state, action) => ({...state, ...action});
export const useStateObject = initialState => {
const [state, setState] = useReducer(reducer, initialState);
return [state, setState];
};
With this custom hook, if we execute
setState({status: 'loading'});
then the placeData
property remains intact. (This is a technique I learned from Epic React’s “Advanced React Hooks” module.)
So the code written so far in the previous sections is refactored as follows:
import {useContext, useState} from 'react';
import {PlaceIdContext} from './PlaceIdContext';
import {useStateObject} from './useStateObject'; // ADDED
export const SearchedPlace = ({mapObject}) => {
const [placeId, setPlaceId] = useContext(PlaceIdContext);
// REVISED FROM HERE
const [state, setState] = useStateObject({
status: 'closed',
placeData: null,
});
const {status, placeData} = state;
// REVISED UNTIL HERE
useEffect(() => {
if (!placeId) return;
setState({status: 'loading'}); // REVISED
const google = window.google;
const service = new google.maps.places.PlacesService(mapObject);
const request = {
placeId: placeId,
fields: [
'formatted_address',
'geometry',
'name',
'url',
],
};
service.getDetails(request, handleResponse);
function handleResponse(place, placesServiceStatus) {
if (placesServiceStatus === 'OK') {
// Omitted for brevity
} else {
console.error('Google Maps Place Details API call has failed.');
setState({status: 'error'}); // REVISED
}
}
}, [mapObject, placeId]);
if status === 'closed' {
return null;
} else if (status === 'loading') {
return (
<div>
<p aria-live="polite" role="status">Getting more information about this place...</p>
</div>
)
} else if (status === 'error') {
return (
{/* Omitted for brevity */}
)
}
}
4.2 Render the place data in a popup window
Then, after fetching the place data from the Google Maps server and dropping a place mark onto its location, we update placeData
with the fetched data:
service.getDetails(request, handleResponse);
function handleResponse(place, placesServiceStatus) {
if (placesServiceStatus === 'OK') {
const searchedPlace = {
address: place.formatted_address,
coordinates: {
lat: place.geometry.location.lat(),
lng: place.geometry.location.lng(),
},
name: place.name,
url: place.url,
};
... // omitted for brevity
marker.current.setMap(mapObject);
mapObject.panTo(searchedPlace.coordinates);
// ADDED FROM HERE
setState({
status: 'open',
placeData: searchedPlace
});
// ADDED UNTIL HERE
} else {
console.error('Google Maps Place Details API call has failed.');
setState({status: 'error'});
}
}
where I also update status
to the string value of 'open'
, to switch the HTML element to render from the loading message to the place information popup.
Now the searchedPlace
component gets re-rendered with a new value of placeData
, which is used to render HTML:
...
if status === 'closed' {
return null;
} else if (status === 'loading') {
return (
<div>
<p aria-live="polite" role="status">Getting more information about this place...</p>
</div>
)
} else if (status === 'error') {
return (
<FocusLock>
<div role="alertdialog" aria-describedby="error-message" aria-labelledby="error-title">
<h2 id="error-title">
Unable to get place detail
</h2>
<p id="error-message">
{/* Error message text omitted for brevity */}
</p>
<button
data-autofocus
onClick={() => setStatus('closed')}
type="button"
>
Got It
</button>
</div>
</FocusLock>
)
{/* ADDED FROM HERE */}
} else if (status === 'open') {
return (
<FocusLock>
<div
aria-label={placeData.name}
role="dialog"
>
<h2>{placeData.name}</h2>
<p>{placeData.address}</p>
<button
data-autofocus
onClick={
/* Click handler to save the searched place
into the user's database in the server
(to be specified in a future post of this blog series)
*/
}
type="button"
>
Save
</button>
<a href={placeData.url} rel="noreferrer" target="_blank">More Info</a>
</div>
</FocusLock>
)
{/* ADDED UNTIL HERE */}
}
Like what we have done with the error message dialog in Section 2.4 above, we wrap the entire dialog with the <FocusLock>
component from the react-focus-lock
library. This way the keyboard user cannot move the focus outside the popup by pressing the Tab key until they close the popup (which will be implemented below in Section 4.3). I believe that this is what the keyboard user expects when a popup shows up.
Also, again like what we have done with the error message dialog, the data-autofocus
attribute, together with the <FocusLock>
component, specifies the HTML element to be auto-focused when the popup is rendered. I add this attribute to the <button>
element which will act as a button to save the searched place into the user’s database in the server (to be discussed in the future post of this blog series)
With <div role="dialog">
alongside the focus moving into its child element, the screen reader will announce the accessible name of the <div>
element, which is the place name specified with aria-label
. You may wonder why I don’t use aria-labelledby
to refer to the <h2>
element instead. I tried this approach, but MacOS screen reader, VoiceOver, does not announce the place name. According to Chapman (2022), this happens if the element referred to by the aria-labelledby
attribute is not yet rendered when the <div role="dialog">
gets rendered. A work around, also suggested by Chapman (2022), is to use aria-label
.
In addition, I add the link to Google Maps’s own page on the place that will open in a new tab, with target="_blank"
. Usually, forcing the user to open a link in a new tab is not a good UX practice (Coyier 2014, Roselli 2020). However, in this particular case, we do not want to kill the app’s active session by taking the user to the linked page on the same browser tab.
But whenever we use target="_blank"
, we should also use ref="noreferrer"
so that the server of a linked page will not be able to see the current page URL which may contain privacy information such as the user’s name (MDN Contributors 2023).
4.3 Close the place detail popup
The user may want to close the place detail popup and to explore the area around it on the embedded Google Maps: maybe there are some places that the user has saved before.
My Ideal Map provides three ways to close the place detail popup:
(1) Press the close button at the top-right corner of the popup;
(2) Tap the area outside the popup; and
(3) Press the Esc key in the keyboard.
To implement these user experiences, let’s first define the function to close the popup.
const closePlaceInfo = () => {
setState({
status: 'closed',
});
};
By setting the status
property of the state
object to be "closed"
, the SearchedPlace
component gets rerendered to return null
in the following part of the code:
...
if status === 'closed' {
return null;
} else if (status === 'loading') {
...
So it removes the HTML elements that consititute the popup.
(1) Close button
Now we add a button to run this function to the place detail popup:
...
} else if (status === 'open') {
return (
<div
aria-label={placeData.name}
role="dialog"
>
<h2>{placeData.name}</h2>
<p>{placeData.address}</p>
<a href={placeData.url} rel="noreferrer" target="_blank">More Info</a>
<button aria-label="Close the place detail" onClick={closePlaceInfo} type="button">
{/* Insert the SVG code for close button icon */}
</button>
</div>
)
}
...
For more detail on the button to close a popup, see Day 18 of this blog series.
(2) Outside the popup
To close the popup by pressing anywhere outside of it, I adapt the approach proposed by Akymenko (2019).
We first need to refer to the popup <div>
with the useRef
hook:
export const SearchedPlace = ({mapObject}) => {
...
const dialogDiv = useRef(null); // ADDED
if status === 'closed' {
return null;
} else if (status === 'loading') {
{/* Omitted for brevity */}
} else if (status === 'error') {
{/* Omitted for brevity */}
} else if (status === 'open') {
return (
<div
aria-label={placeData.name}
ref={dialogDiv} // ADDED
role="dialog"
>
<h2>{placeData.name}</h2>
<p>{placeData.address}</p>
<a href={placeData.url} rel="noreferrer" target="_blank">More Info</a>
<button aria-label="Close the place detail" onClick={closePlaceInfo} type="button">
{/* Insert the SVG code for close button icon */}
</button>
</div>
)
}
}
Then run an additional useEffect
hook to attach the pointerdown
event handler to the document
object:
const dialogDiv = useRef(null);
// ADDED FROM HERE
useEffect(() => {
const listener = event => {
if (!dialogDiv.current || dialogDiv.current.contains(event.target)) {
return;
}
closePlaceInfo();
};
document.addEventListener('pointerdown', listener);
return () => {
document.removeEventListener('pointerdown', listener);
};
}, [closePlaceInfo, dialogDiv]);
// ADDED UNTIL HERE
if status === 'closed' {
return null;
} else if (status === 'loading') {
...
The crucial part in the above code is
dialogDiv.current.contains(event.target)
This returns true
if the event.target
(the HTLM element that receives the user’s interaction) refers to the dialogDiv
itself or any of its child elements, that is, when the user clicks somewhere inside the popup. Otherwise, it returns false
and thus the useEffect
hook continues to run and execute closePlaceInfo()
to close the popup.
And this event handler is attached to the pointerdown
event of the document
object, which means both the desktop user clicks anywhere in the app and the mobile user taps anywhere in the app.
To remove this event handler whenever the SearchedPlace
component gets dismounted, we return the function to remove the event listener — the standard practice to use the useEffect
hook (see React documentation).
(3) The ESC key
To close the popup by pressing the Esc key in the keyboard, we actually use the useEffect
hook in a similar fashion:
useEffect(() => {
const closeByEsc = event => {
if (event.key === 'Escape') {
closePlaceInfo();
}
};
if (status === 'open') {
document.addEventListener('keydown', closeByEsc);
} else {
document.removeEventListener('keydown', closeByEsc);
}
return () => {
document.removeEventListener('keydown', closeByEsc);
};
}, [closePlaceInfo, status]);
The event handler, closeByEsc
, checks if the user presses the Esc key (i.e., if event.key === 'Escape'
is true
) and, if so, executes the closePlaceInfo
function to close the popup. And this event handler will be attached to the document
object if status==='open'
is true
, that is, when the popup is opened. It will be removed if status
takes another value, that is, when the popup is closed. By having status
as its dependency, this useEffect
hook runs every time the status
gets updated.
Since this event only concerns the keyboard user, we use keydown
event, rather than pointerdown
event.
4.4 Reopen the popup by pressing the place mark
The place detail popup can now be closed by the user. In case the user wants to see it again, we let them do so by clicking the place mark on the embedded Google Maps.
To add a click handler to the place mark, we revise the handleResponse
callback function as follows:
service.getDetails(request, handleResponse);
function handleResponse(place, placesServiceStatus) {
if (placesServiceStatus === 'OK') {
const searchedPlace = {
// Omitted for brevity
};
const searchedPlaceMarker = {
// Omitted for brevity
}
marker.current = new google.maps.Marker({
icon: {
url: searchedPlaceMarker.filePath,
anchor: new google.maps.Point(
searchedPlaceMarker.width / 2,
searchedPlaceMarker.height / 2,
),
},
optimized: false, // ADDED
position: searchedPlace.coordinates,
title: "searchedPlace.name,"
});
// ADDED FROM HERE
marker.current.addListener('click', () => {
setState({status: 'open'});
});
// ADDED UNTIL HERE
marker.current.setMap(mapObject);
mapObject.panTo(searchedPlace.coordinates);
setState({
status: 'open',
placeData: searchedPlace
});
} else {
// Omitted for brevity
}
}
The marker
object has a method addListener()
which attaches an event listener to the place mark.
By having an event listener attached, the place mark on embedded Google Maps becomes accessible: it is rendered as a <button>
element focusable with the Tab key with the title
property value used as its accessible name. For this purpose, however, there is one extra thing to do, according to the Google Maps Platform documentation: add optimized: false
to the set of options to create a marker in the first place. By default, this option is true
, which means a group of markers may be rendered as a single marker for performance (see Google Maps Platform documentation). We need to avoid this from happening.
Now style the popup as you wish. In my case, I style it like a cloud floating above city streets (see Day 19 of this blog series).
5. Final words
That’s all! What a long article needed to explain how to show a searched place on the Google Maps embedded in a React app...
No demo available...
I apologize for the lack of a demo of the code described in this article. This is because Google Maps charges me every time the user loads a map and fetches the data on places via the web app that embeds Google Maps.
References
Abramov, Dan (2017) “As mentioned in the user guide, you need to explicitly read any global variables from window
...”, Stack Overflow, May 1, 2017.
Akymenko, Maks (2019) “Hamburger Menu with a Side of React Hooks and Styled Components”, CSS-Tricks, Sep 10, 2019.
Bischoff, Ashley (2016) “aria-live
triggers screen readers when an element with aria-live
(or text within an element with aria-live
) is added or removed from the DOM...”, Stack Overflow, Jul 26, 2016.
Chapman, George (2022) “There's nothing wrong with the way you're using aria-labelledby, however it may be that the content of your modal (#modal-desc
) is being added after the modal container (#modal
)...”, Stack Overflow, Mar 28, 2022.
Coyier, Chris (2014) “When to use target=”_blank””, CSS-Tricks, Jan 15, 2014.
danhardman (2014) “According to the reference: panTo: Changes the center of the map to the given LatLng...”, Stack Overflow, Oct 31, 2014.
Dodds, Kent C. (2018) “Prop Drilling”, kentcdodds.com, May 21, 2018.
Dodds, Kent C. (2021) “How to use React Context effectively”, kentcdodds.com, Jun 5, 2021.
Friedman, Vitaly (2022) “A Complete Guide To Accessible Front-End Components”, Smashing Magazine, May 25, 2022.
Golcic, Hrvoje (2020) “Accessible way of notifying a screen reader about loading the dynamic Web page update (AJAX)”, Stack Exchange, Feb 28, 2020.
Kudamatsu, Masa (2021) “4 gotchas when setting up Google Maps API with Next.js and ESLint”, Dev Community, Feb 12, 2021.
MDN Contributors (2023) “Referer header: privacy and security concerns”, MDN Web Docs, Mar 2, 2023 (last updated).
Roselli, Adrian (2020) “Link Targets and 3.2.5”, adrianroselli.com, Feb 7, 2020.
Top comments (0)