Day 26: Integrating Google Maps search with a React app

masakudamatsu

Masa Kudamatsu

Posted on July 2, 2023

Day 26: Integrating Google Maps search with a React app

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>
    )
  }
}
Enter fullscreen mode Exit fullscreen mode

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];
};
Enter fullscreen mode Exit fullscreen mode

(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
});
Enter fullscreen mode Exit fullscreen mode

(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} />;
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Call Google Maps API to retrieve the information of the place the user has selected (Section 2);
  2. Drop a pin onto the map (Section 3);
  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
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
  });
Enter fullscreen mode Exit fullscreen mode

(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',
  ],
};
Enter fullscreen mode Exit fullscreen mode

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

  1. The place’s name (name),
  2. Its longitude and latitude (geometry) to drop a pin on the map,
  3. Its street address (formatted_address) to make sure it is not a place of the same name in a different city, and
  4. 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);
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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} />;
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

(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
  })}
>
Enter fullscreen mode Exit fullscreen mode

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
  })}
>
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
    ...
Enter fullscreen mode Exit fullscreen mode

(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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
    ...
Enter fullscreen mode Exit fullscreen mode

(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"
},
Enter fullscreen mode Exit fullscreen mode

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);
...
Enter fullscreen mode Exit fullscreen mode

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.');
      }
    }
  ...
Enter fullscreen mode Exit fullscreen mode

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
      }
    }
  ...
Enter fullscreen mode Exit fullscreen mode

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
  }
  ...
Enter fullscreen mode Exit fullscreen mode

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
  }
  ...
Enter fullscreen mode Exit fullscreen mode

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');
      }
    }
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode

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
},
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
  ...
Enter fullscreen mode Exit fullscreen mode

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;
  ...
Enter fullscreen mode Exit fullscreen mode

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,
})
Enter fullscreen mode Exit fullscreen mode

which requires an extra redundant line of code. Plus, in case I mistakenly write the code as follows

setState({
  status: 'loading',
})
Enter fullscreen mode Exit fullscreen mode

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];
};
Enter fullscreen mode Exit fullscreen mode

With this custom hook, if we execute

setState({status: 'loading'});
Enter fullscreen mode Exit fullscreen mode

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 */}
    )
  }
}
Enter fullscreen mode Exit fullscreen mode

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'});
  }
}
Enter fullscreen mode Exit fullscreen mode

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 */}
  }
Enter fullscreen mode Exit fullscreen mode

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',
  });
};
Enter fullscreen mode Exit fullscreen mode

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') {
...
Enter fullscreen mode Exit fullscreen mode

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>
    )
  }
  ...
Enter fullscreen mode Exit fullscreen mode

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>
    )
  }  
}
Enter fullscreen mode Exit fullscreen mode

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') {
  ...
Enter fullscreen mode Exit fullscreen mode

The crucial part in the above code is

dialogDiv.current.contains(event.target)
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

💖 💪 🙅 🚩
masakudamatsu
Masa Kudamatsu

Posted on July 2, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related