DEV Community

Cover image for Day 25: Adding Google Maps autocomplete search to a React app
Masa Kudamatsu
Masa Kudamatsu

Posted on • Updated on • Originally published at Medium

Day 25: Adding Google Maps autocomplete search to a React app

TL;DR

To create an user experience with Google Maps place search like this:A search box is shown with text cursor flashing in it. As characters
your React app needs the code as follows:

// Each comment refers to the section of this article 
// which explains the code block that follows.

import {useMemo, useState} from 'react';
import {useCombobox} from 'downshift';

export const SearchBox = () => {
  // Section 5.1: to update UI as search suggestion list or Google Maps server status changes
  const [searchResult, setSearchResult] = useState({
    autocompleteSuggestions: [],
    status: '',
  });
  // Section 2.2: to make an API call to Google Maps server
  const google = window.google;
  const service = new google.maps.places.AutocompleteService();
  // Section 3: to save money for using Google Maps autocomplete search
  const sessionToken = useMemo(
    () => new google.maps.places.AutocompleteSessionToken(),
    [google.maps.places.AutocompleteSessionToken],
  );
  // Section 5.4: to make autocomplete search accessible
  const {
    getInputProps,
    getItemProps,
    getMenuProps,
  } = useCombobox({
    items: searchResult.autocompleteSuggestions,
    onInputValueChange: ({inputValue}) => {
      // Section 6.1: remove search suggestions when the search term is deleted in search box
      if (inputValue === '') {
        setSearchResult({
          autocompleteSuggestions: [],
          status: '',                 
        });
        return;
      }
      // Section 4.1: make an API call to Google Maps server
      service.getPlacePredictions({
        input: inputValue,         
        sessionToken: sessionToken,
        }, handlePredictions       
      );   
      // Section 4.2: process the response from Google Maps server
      function handlePredictions(predictions, status) {
        if (status === "OK") {
          const autocompleteSuggestions = predictions.map((prediction) => {
            return {
              id: prediction.place_id,
              name: {
                string: prediction.structured_formatting.main_text,
                // Section 7.3: to highlight the search term in suggested place names
                length: prediction.structured_formatting.main_text_matched_substrings[0]['length'],
                offset: prediction.structured_formatting.main_text_matched_substrings[0]['offset'],
              },
              address: {
                string: prediction.structured_formatting.secondary_text,
                // Section 7.3: to highlight the search term in suggested place address
                length: prediction.structured_formatting.secondary_text_matched_substrings[0]['length'],
                offset: prediction.structured_formatting.secondary_text_matched_substrings[0]['offset'],
              },
            };
          });
          // Section 5.2: update UI with new search suggestions
          setSearchResult({
            autocompleteSuggestions: autocompleteSuggestions,
            status: 'OK',
          })
        } else {
          // Section 5.2: update UI with an error message
          setSearchResult({
            autocompleteSuggestions: [],
            status: status,             
          });
        }
      }
    }
  })
  // Section 5.4: render UI in an accessible way
  return (
    <>
      <input 
        type="search"
        {...getInputProps()}
      >
      <ul
        {...getMenuProps()}
      >
        { // Section 5.3: show search suggestions
          searchResult.autocompleteSuggestions.length > 0
          ? searchResult.autocompleteSuggestions.map((item, index) => {
              return (
                <li
                  key={item.id}
                  {...getItemProps({
                    item,
                    index
                  })}
                >
                  {/* Section 7.3: highlight the search term in search suggestions */}
                  <p dangerouslySetInnerHTML={{__html: boldUserText(item.name)}}>
                  <p dangerouslySetInnerHTML={{__html: boldUserText(item.address)}}>
                </li>
              );
            })
          : null
        }
      </ul>
      {/* Section 6.2: show error messages */} 
      <SearchErrorMessage status={searchResult.status} />
    </>
  ) 
};
Enter fullscreen mode Exit fullscreen mode

where the boldUserText function and the SearchErrorMessage component is given as follows:

// Section 7.3: to highlight the search term in search suggestions
function boldUserText({ length, offset, string }) {
  if (length === 0 && offset === 0) {
    return string;
  }
  const userText = string.substring(offset, offset + length);
  const stringBefore = string.substring(0, offset);
  const stringAfter = string.substring(offset + length);
  return `${stringBefore}<b>${userText}</b>${stringAfter}`;
}
Enter fullscreen mode Exit fullscreen mode
export const SearchErrorMessage = ({status}) => {
  // Section 6.2: switch error messages in response to Google Maps server status
  return status === '' || status === 'OK' ? null : (
    <div role="alert">
      {
        status === 'ZERO_RESULTS' || status === 'INVALID_REQUEST' || status === 'NOT_FOUND' ? (
          <p>
            No place is found on the map. Try another search term.
          </p>
        {/* Section 6.3: show an error message when server access is rejected */}
        ) : status === 'OVER_QUERY_LIMIT' || status === 'REQUEST_DENIED' ? (
          <p>
            My Ideal Map is currently unable to use Google Maps search. Please contact us so we can fix the problem.
          </p>
        {/* Section 6.4: show an error message when Google Maps server is not responding */}
        ) : (
          <p>
            Google Maps server is down. <a href="https://status.cloud.google.com/maps-platform/products/i3CZYPyLB1zevsm2AV6M/history" target="_blank" rel="noreferrer">Please check its status</a>, and try again once they fix the problem (usually within a few hours).
          </p>
        )
      }
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Then style the <ul> and <li> elements with CSS as you wish.

1. Introduction

My Ideal Map, a web app I’m building with React, allows the user to save the places of their own interest on Google Maps with rich text note. For this purpose, the app needs to incorporate the search feature of Google Maps so that the user can first find each place of their interest before saving it.

The Places Library of Google Maps JavaScript API allows you to embed Google Maps search into your own web app.

As far as I know, there are only two articles on the web that explain how to incorporate the Places Library into React apps. Both Kassym (2020) and Imoh (2022) use the Autocomplete widget, which comes with the predefined user interface.

For more flexibility in UI design, however, we need to use the AutocompleteService class, which only fetches the JSON data of autocomplete search suggestions from the Google Maps server.

After quite a bit of trial and error, I have managed to incorporate Google Maps search into a React app with my own user interface (as shown in the GIF image above). Here’s how.

2. Setting up

2.1 Enabling the Place Library

The Place Library is not available to use by default. We need to enable it on Google Cloud Console.

How to do so is clearly explained in Google Maps Platform documentation.

Once enabled, the Place Library needs to be added to the list of APIs that your Google Maps API key can be used for.

How to do so is also clearly explained in Google Maps Platform documentation.

2.2 Loading the Places library

To embed Google Maps in a React app, we need the following code:

import {useEffect, useRef} from 'react';
import {Loader} from '@googlemaps/js-api-loader';
export function HomePage() {
  const googlemap = useRef(null);
  useEffect(() => {
    const loader = new Loader({
      apiKey: process.env.NEXT_PUBLIC_API_KEY,
      version: 'weekly',
    });
    let map;
    loader.load().then(() => {
      map = new google.maps.Map(googlemap.current, {
        center: {lat: -34.397, lng: 150.644}, // or anywhere you want to show on the map by defaul
        zoom: 8, // or any other zoom level
      });
    });
  });
  return (
    <div id="map" ref={googlemap} />
  );
}
Enter fullscreen mode Exit fullscreen mode

where I use Next.js’s environment variable NEXT_PUBLIC_API_KEY to keep the API key secret (see my article, Kudamatsu (2021), for more detail).

To use the Places Library, we need to change the options for the Loader class as follows:

const loader = new Loader({
  apiKey: process.env.NEXT_PUBLIC_API_KEY,
  version: 'weekly',
  libraries: ['places'], // ADDED
});
Enter fullscreen mode Exit fullscreen mode

Surprisingly, you cannot find this code snippet in Google Maps Platform documentation on the Places Library. I only found it in the GitHub repo for googlemaps/js-api-loader.

2.3 Instantiate AutocompleteService class

Now the Places Library is available as google.maps.places. For flexible UI design on autocomplete search, we need to use its AutocompleteService class:

const google = window.google;
const service = new google.maps.places.AutocompleteService();
Enter fullscreen mode Exit fullscreen mode

where the first line of code is to silence ESLint, which will otherwise complain that google is used without being defined (see Abramov 2017) for detail). The second line of code comes from Google Maps Platform documentation.

3. Session tokens

3.1 How Google charges us for using autocomplete search

The autocomplete search of Google Maps is NOT free of charge. By default, each time the user enters a character to retrieve autocomplete suggestions, it costs 0.00283 US dollars (source: Google Maps Platform documentation). In addition, when the user clicks one of the autocomplete suggestions, it costs 0.017 US dollars (source: Google Maps Platform documentation).

But there is a cheaper price plan. If the request to the Google Maps server is sent with a session token, then, it will only cost 0.017 US dollars for the the whole process of autocomplete search: entering search words and choosing an autocomplete suggestion (source: Google Maps Platform documentation). If the user ends up not choosing any of autocomplete suggestions, it will also cost 0.017 US dollars (source: Google Maps Platform documentation).

To benefit from this price plan, a single session token needs to be sent to the server during the whole process of autocomplete search. And a new session token must be used for a new search; otherwise the default price plan (charged per character to enter) will be applied (source: Google Maps Platform documentation).

Consequently, we first need to generate a session token before sending the user's search text to the Google Maps server.

3.2 Generating session tokens for React apps

To generate a session token, Google Maps Platform documentation teaches us to code like this:

const sessionToken = new google.maps.places.AutocompleteSessionToken();
Enter fullscreen mode Exit fullscreen mode

However, this line of code doesn’t really work with React apps. The search term needs to be managed as a React state so that the search box gets re-rendered each time the user enters a character in it. Consequently, whenever the user changes the search text, the whole code of the search box component gets executed, with a new session token generated and sent to the Google Maps server. This kills the whole idea of using a session token to save money.

To make sure the same session token will be sent to the server until the user finishes a search process, we need to use useMemo():

const sessionToken = useMemo(
  () => new google.maps.places.AutocompleteSessionToken(),
  [google.maps.places.AutocompleteSessionToken],
);
Enter fullscreen mode Exit fullscreen mode

This way, even if the React component gets rerendered due to changes in the search term, the session token will remain the same.

On the other hand, when the search process is over, my React app dismounts the search box component. Consequently, when the user starts a new search process, a new search box component is rendered with a new session token issued by the above line of code. (If the search box needs to be shown all the time, a different solution is needed for which I don’t know what to do.)

4. Interacting with API

Now we’re ready to interact with the Google Maps server. First, we send the search term to the server (Section 4.1). Then, we process the response returned by the server (Section 4.2).

4.1 Making an API call

In a simple setting, a request for autocomplete search can be sent to the server with the following code (source: Google Maps Platform documentation):

service.getPlacePredictions(
  {
    input: 'pizza near Syd',
    sessionToken: sessionToken,
  },
  handlePredictions,
);
Enter fullscreen mode Exit fullscreen mode

where

  • service is an instance of AutocompleteService class (see Section 2.3 above)
  • input specifies the search term entered by the user
  • sessionToken is the one generated with AutocompleteSessionToken() (see Section 3.2 above)
  • handlePredictions is a function to process autocomplete search results (to be defined below)

There are a set of optional parameters for the getPlacePredictions() method. Among them, location and radius can be set so that search suggestions will be biased towards a certain area such as the user’s city of residence (see Google Maps Platform documentation for detail).

I’m not sure whether I want this feature. If the user is planning a trip, biasing towards where they live is certainly not helpful. Allowing the user to change the location bias will just complicate the user experience of searching a place.


The AutocompleteService class has another function called getQueryPredictions(), which will retrieve not only autocomplete suggestions but also suggested search terms like “pizza in New York” when the user just enters “pizza in new” (source: Google Maps Platform docs).

For the purpose of My Ideal Map, this extra suggestion is unnecessary. The app is for those users who knows a place of their interest, not for those who rely on Google Maps to suggest where to visit.

4.2 Processing server responses

We now need to specify the handlePredictions function to process the autocomplete suggestion data returned from the Google Maps server.

Once we send a request by calling getPlacePredictions(), we will receive two sets of data, predictions and status. So the handlePredictions function should take these two variables as its arguments:

function handlePredictions(predictions, status) {}
Enter fullscreen mode Exit fullscreen mode

The status variable indicates whether the API request is successful (see Google Maps Platform documentation). If successful, its value is "OK". So I code as follows:

function handlePredictions(predictions, status) {
  if (status === "OK") {
    // handle autocomplete suggestions
  } else {
    // handle error
  }
}
Enter fullscreen mode Exit fullscreen mode

The predictions variable refers to an array of objects each of which contains an autocomplete suggestion. Here is an example of those objects (it’s one of my favorite museums in Kyoto):

{
  "description" : "Fukuda Art Museum, 3-16 Sagatenryuji Susukinobabacho, Ukyo Ward, Kyoto, Japan",
  "matched_substrings" : [
    {
        "length" : 8,
        "offset" : 0
    },
    {
        "length" : 33,
        "offset" : 19
    }
  ],
  "place_id" : "ChIJfwxa4v-pAWARQ-R4_cVAgAc",
  "reference" : "ChIJfwxa4v-pAWARQ-R4_cVAgAc",
  "structured_formatting" : {
    "main_text" : "Fukuda Art Museum",
    "main_text_matched_substrings" : [
        {
          "length" : 8,
          "offset" : 0
        }
    ],
    "secondary_text" : "3-16 Sagatenryuji Susukinobabacho, Ukyo Ward, Kyoto, Japan",
    "secondary_text_matched_substrings" : [
        {
          "length" : 33,
          "offset" : 0
        }
    ]
  },
  "terms" : [
    {
        "offset" : 0,
        "value" : "Fukuda Art Museum"
    },
    {
        "offset" : 19,
        "value" : "3-16 Sagatenryuji Susukinobabacho"
    },
    {
        "offset" : 54,
        "value" : "Ukyo Ward"
    },
    {
        "offset" : 65,
        "value" : "Kyoto"
    },
    {
        "offset" : 72,
        "value" : "Japan"
    }
  ],
  "types" : [ "museum", "point_of_interest", "establishment" ]
}
Enter fullscreen mode Exit fullscreen mode

For the definition of each property, see Google Maps Platform documentation.

For the purpose of My Ideal Map, all I need is place_id and structured_formatting properties.

When the user selects one of the autocomplete suggestions, My Ideal Map makes another API call to the Google Maps server so that its location will be displayed on the map along with additional pieces of information on the place. To do so, we need place_id.

To select one of the autocomplete suggestions, the user needs to see the name of the place and its address (the address is crucial as there are the places with the same name in different cities). The structured_formatting property contains an object whose main_text and secondary_text properties provide the name and address, respectively, of the place.

Consequently, I transform the predictions array as follows:

function handlePredictions(predictions, status) {
  if (status === "OK") {
    // ADDED FROM HERE
    const autocompleteSuggestions = predictions.map((prediction) => {
      return {
        id: prediction.place_id,
        name: {
          string: prediction.structured_formatting.main_text,
        },
        address: {
          string: prediction.structured_formatting.secondary_text,
        },
      };
    });
    // ADDED UNTIL HERE
  } else {
    // handle error
  }
}
Enter fullscreen mode Exit fullscreen mode

You might wonder why I use name.string and address.string, rather than name and address, to store the name and address of a place. Please be patient. I'll explain it in Section 7 below.

The autocompleteSuggestions array will then be used to render the list of suggested places in response to the user’s search text.

To do so, I use React’s state with the help of a library called Downshift.

5. Rendering autocomplete suggestions with React

5.1 Initializing the state

First, I create a state variable called searchResult with the useState hook of React:

const [searchResult, setSearchResult] = useState({
  autocompleteSuggestions: [],
  status: '',
}); 
Enter fullscreen mode Exit fullscreen mode

This state variable is an object with two properties. One of them, autocompleteSuggestions, is to store the array of suggestions returned by Google Maps API. The other property, status, will store the status variable returned from Google Maps API.

The status property is needed as part of the React state so that I can update the UI with an error message when the Google Maps server returns a value of status other than "OK".

And I manage these two states as one object, rather than two separate state variables, because they get updated at the same time (you’ll see this in action shortly).

5.2 Updating the state

With setSearchResult to update the React state, the handlePredictions function is revised as follows:

function handlePredictions(predictions, status) {
  if (status === "OK") {
    const autocompleteSuggestions = predictions.map((prediction) => {
      return {
        id: prediction.place_id,
        name: {
          string: prediction.structured_formatting.main_text,
        },
        address: {
          string: prediction.structured_formatting.secondary_text,
        },
      };
    });
    // ADDED FROM HERE
    setSearchResult({
      autocompleteSuggestions: autocompleteSuggestions,
      status: 'OK',
    })
    // ADDED UNTIL HERE
  } else {
    // ADDED FROM HERE
    setSearchResult({
      autocompleteSuggestions: [],
      status: status,
    })
    // ADDED UNTIL HERE
  }
}
Enter fullscreen mode Exit fullscreen mode

When search is successful (status==="OK"), the React state gets updated with the autocompleteSuggestions array and the string of "OK". Otherwise, it is updated with an empty array and the status variable. You’ll shortly see why this way of updating the React state is convenient.

5.3 Rendering autocomplete suggestions

With the array of autocomplete suggestions, saved as searchResult.autocompleteSuggestions, <li> elements can be rendered with React as follows:

searchResult.autocompleteSuggestions.length > 0
  ? searchResult.autocompleteSuggestions.map((item) => {
      return (
        <li key={item.id}>
          <p>{item.name.string}</p>
          <p>{item.address.string}</p>
        </li>
      );
    })
  : null
Enter fullscreen mode Exit fullscreen mode

First, I check whether the array is empty by looking at its length property. If it’s 0 (which is the default value, that is, before the user types any character in the search box), I don’t render any <li> element. (This is why, in the previous subsection, I set searchResult.autocompleteSuggestions to be an empty array when status is not "OK": in such a case, we don’t want to render <li> elements).

Second, I use the place ID for the key prop, which is necessary for React to remember each <li> element across re-rendering (see Dodds 2017 for demo).

Third, I insert each of the place name and its address into the <p> element. These are not paragraphs, but I don’t see any other HTML elements more appropriate. The <address> element is for the contact information, not for addresses in general (see Section 4.3.10 of HTML Living Standard).


There remain two things still missing in the code built so far. First, we need to make an API call to the Google Maps server whenever the user changes the search term in the search box. Second, the whole user experience of autocomplete search needs to be accessible.

For implementing these two, I use a library called Downshift, which makes life a lot easier than coding from scratch (see Day 24 of this blog series for detail).

5.4 Using Downshift

First, import the useCombobox hook from the downshift NPM module:

import {useCombobox} from 'downshift';
Enter fullscreen mode Exit fullscreen mode

Then, specify the array of autocomplete suggestions as the items option:

useCombobox({
  items: searchResult.autocompleteSuggestions,
})
Enter fullscreen mode Exit fullscreen mode

The useCombobox hook returns what they call prop getters:

const {
  getInputProps,
  getItemProps,
  getMenuProps,
} = useCombobox({
  items: searchResult.autocompleteSuggestions,
})
Enter fullscreen mode Exit fullscreen mode

We use these prop getters to (1) add ARIA attributes to <input>, <ul>, and <li> elements and (2) make these elements work in tandem to provide accessibility:

return (
  <>
    <input 
      type="search"
      {...getInputProps()}
    >
    <ul
      {...getMenuProps()}
    >
      {searchResult.autocompleteSuggestions.length > 0
        ? searchResult.autocompleteSuggestions.map((item, index) => {
            return (
              <li
                key={item.id}
                {...getItemProps({
                  item,
                  index
                })}
              >
                <p>{item.name.string}</p>
                <p>{item.address.string}</p>
              </li>
            );
          })
        : null
      }
    </ul>   
  </>
)
Enter fullscreen mode Exit fullscreen mode

These prop getters return an object of HTML attributes, which is why we use the notations like {...getInputProps()}.

The getItemProps() requires as arguments both the array item (item) and its index (index). This is why we add index as the argument for a function specified in the .map() array method.

There are a few more things to code for using the useCombobox hook of Downshift, but I just refer you to Day 24 of this blog series (see Section 3).


Now it’s time to code for making an API call to the Google Maps server whenever the user types something in the search box. All we need to do is to specify as the useCombobox hook’s onInputValueChange option the series of functions to execute for making an API call (see Sections 4.1, 4.2 and 5.2 above):

const {
  getInputProps,
  getItemProps,
  getMenuProps,
} = useCombobox({
  items: searchResult.autocompleteSuggestions,
  // ADDED FROM HERE
  onInputValueChange: ({inputValue}) => {
    // Section 4.1
    service.getPlacePredictions({
      input: inputValue,         
      sessionToken: sessionToken,
      }, handlePredictions       
    );                     
    // Section 4.2      
    function handlePredictions(predictions, status) {
      if (status === "OK") {
        const autocompleteSuggestions = predictions.map((prediction) => {
          return {
            id: prediction.place_id,
            name: {
              string: prediction.structured_formatting.main_text,
            },
            address: {
              string: prediction.structured_formatting.secondary_text,
            },
          };
        });
        // Section 5.2
        setSearchResult({
          autocompleteSuggestions: autocompleteSuggestions,
          status: 'OK',
        })
      } else {
        // Section 5.2
        setSearchResult({
          autocompleteSuggestions: [],
          status: status,             
        });
      }
    }
  }
  // ADDED UNTIL HERE
})
Enter fullscreen mode Exit fullscreen mode

Downshift assigns to the inputValue variable the text the user has entered into the search box. It is then used as the input parameter for the getPlacePredictions function so that the Google Maps server retrieves autocomplete suggestions in response to the user input.

In the code above, I use hoisting for handlePredictions. When a function is defined with the function keyword, it can be used before it is defined (MDN Contributors 2023). As far as I know, this way of coding is common in Python, but not in JavaScript. However, in this particular case where the getPlacePredictions function specifies a callback function as its argument, I believe it improves code readability: the functions are executed in the order of being written in the code.

5.5 Putting all the code together so far

With the code written so far, whenever the user types a character in the search box, the app renders the list of autocomplete suggestions. We still need to handle edge cases and to improve the user interface. But let's take stock by putting all the code together so far, with references to the relevant sections as comments:

import {useMemo, useState} from 'react';
import {useCombobox} from 'downshift';

export const SearchBox = () => {
  // Section 5.1
  const [searchResult, setSearchResult] = useState({
    autocompleteSuggestions: [],
    status: '',
  });
  // Section 2.2
  const google = window.google;
  const service = new google.maps.places.AutocompleteService();
  // Section 3
  const sessionToken = useMemo(
    () => new google.maps.places.AutocompleteSessionToken(),
    [google.maps.places.AutocompleteSessionToken],
  );
  // Section 5.4
  const {
    getInputProps,
    getItemProps,
    getMenuProps,
  } = useCombobox({
    items: searchResult.autocompleteSuggestions,
    onInputValueChange: ({inputValue}) => {
      // Section 4.1
      service.getPlacePredictions({
        input: inputValue,         
        sessionToken: sessionToken,
        }, handlePredictions       
      );   
      // Section 4.2
      function handlePredictions(predictions, status) {
        if (status === "OK") {
          const autocompleteSuggestions = predictions.map((prediction) => {
            return {
              id: prediction.place_id,
              name: {
                string: prediction.structured_formatting.main_text,
              },
              address: {
                string: prediction.structured_formatting.secondary_text,
              },
            };
          });
          // Section 5.2
          setSearchResult({
            autocompleteSuggestions: autocompleteSuggestions,
            status: 'OK',
          })
        } else {
          // Section 5.2
          setSearchResult({
            autocompleteSuggestions: [],
            status: status,             
          });
        }
      }
    }
  })
  // Section 5.4
  return (
    <>
      <input 
        type="search"
        {...getInputProps()}
      >
      <ul
        {...getMenuProps()}
      >
        { // Section 5.3 
          searchResult.autocompleteSuggestions.length > 0
          ? searchResult.autocompleteSuggestions.map((item, index) => {
              return (
                <li
                  key={item.id}
                  {...getItemProps({
                    item,
                    index
                  })}
                >
                  <p>{item.name.string}</p>
                  <p>{item.address.string}</p>
                </li>
              );
            })
          : null
        }
      </ul>
    </>
  ) 
};
Enter fullscreen mode Exit fullscreen mode

With this code snippet, we can freely style the list of autocomplete suggestions like this:
A search box with semi-circle edges on both sides shows the search term _kyoto_, below which five boxes of the shape similar to the search box show autocomplete suggestions: Kyoto, Kyoto Station, Kyoto University, Kyoto Katsugyu, and Kyoto Railway Museum. In these suggestions, the phrase _Kyoto_ is highlighted in bold.A screenshot of My Ideal Map in development

where the search term is highlighted in bold with the additional set of code described in Section 7 below.

6. Handling edge cases

We need to handle four edge cases: (i) the user deletes the whole search term; (ii) no search result is returned; (iii) access to the Google Maps server is denied; (iv) the Google Maps server is down.

Let's look at each of these four edge cases.

6.1 Deleting the whole search term

The code snippet in Section 5.5 leaves the list of autocomplete suggestions visible when the user deletes the entire search term. This is because the React state isn’t reset when the search box field becomes empty.

To handle this case, we need to revise the useCombobox hook’s onInputValueChange prop, to reset the React state when inputValue is an empty string:

const {
  getInputProps,
  getItemProps,
  getMenuProps,
} = useCombobox({
  items: searchResult.autocompleteSuggestions,
  onInputValueChange: ({inputValue}) => {
    // ADDED FROM HERE
    if (inputValue === '') {
      setSearchResult({
        autocompleteSuggestions: [],
        status: '',                 
      });
      return;
    }
    // ADDED UNTIL HERE
    service.getPlacePredictions({
      input: inputValue,         
      sessionToken: sessionToken,
      }, handlePredictions       
    );   
    function handlePredictions(predictions, status) {
      // Omitted for brevity
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

6.2 No search result

The code snippet in Section 5.5 above does not give any message when there is no search result. The user is then unsure of whether there is no search result or the Google Maps server is not responding. That is not a great user experience.

First of all, what kind of message should I display in case of no search result?

Whitenton (2014) recommends:

  1. Clearly explain that there are no matching results.
  2. Offer starting points for moving forward.
  3. Don't mock the user.

Food.com, mentioned by Whitenton (2014) as a good example, provides the following message for no search result:

Sorry, no results were found. ... Check your spelling. Try more general words. Try different words that mean the same thing.

That was back in 2014. Food.com today gives the following message for no search result:

OOPS! NO MATCHES. How about digging into some of our most popular stuff instead?

I guess I can go with a simple message like this:

No place is found on the map. Try another search term.


Now, how can we display this message in case of no search result?

When the Google Maps server returns no search result, the status variable takes the value of "ZERO_RESULTS" (see Google Maps Platform documentation).

Therefore, we can check the statement:

status === `ZERO_RESULTS`
Enter fullscreen mode Exit fullscreen mode

and if it is true, render the following HTML element:

<div role="alert">
  <p>
    No place is found on the map. Try another search term.
  </p>
</div>
Enter fullscreen mode Exit fullscreen mode

I use role="alert" so that the screen reader will read out the message when this <div> element is inserted into the DOM (see Whiting 2017 for how to use role="alert" properly).

To implement this behavior with React, I create a separate component named SearchErrorMessage which takes status as a prop:

export const SearchErrorMessage = ({status}) => {
  return status === '' || status === 'OK' ? null : (
    <div role="alert">
      {
        status === 'ZERO_RESULTS' ? (
          <p>
            No place is found on the map. Try another search term.
          </p>
        ) : null
      }
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

where it renders nothing when status is an empty string (the initial state) or "OK" (at least one search result is returned). Otherwise, it renders <div role="alert">, the inside of which the value of status is checked if it’s "ZERO_RESULTS". If so, the message for no search result is rendered as a <p> element. This way, we can include some link text in the message, if necessary.

When the status variable takes the string value of "INVALID_REQUEST" (this request is invalid) or "NOT_FOUND" (the place referenced was not found), what the user needs to do is to try another search term. So, even though it is not strictly about the "no search result" situation, I think we can use the same message.

So I add these cases to the above code:

export const SearchErrorMessage = ({status}) => {
  return status === '' || status === 'OK' ? null : (
    <div role="alert">
      {
        // REVISED FROM HERE
        status === 'ZERO_RESULTS' || status === 'INVALID_REQUEST' || status === 'NOT_FOUND' ? (
        // REVISED UNTIL HERE
          <p>
            No place is found on the map. Try another search term.
          </p>
        ) : null
      }
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Then, import and use this component into the SearchBox component as follows:

return (
  <>
    <input 
      type="search"
      {...getInputProps()}
    >
    <ul
      {...getMenuProps()}
    >
      {/* Omitted for brevity */}
    </ul>
    <SearchErrorMessage status={searchResult.status} /> {/* ADDED */}
  </>
);
Enter fullscreen mode Exit fullscreen mode

6.3 Server access is denied

If status is "OVER_QUERY_LIMIT" (The application has gone over its request quota) or "REQUEST_DENIED" (The application is not allowed to use the PlacesService), this is a situation where it is the fault of my app, My Ideal Map, rather than Google Maps.

So the appropriate message is to ask the user to contact me, something like this:

My Ideal Map is currently unable to use Google Maps search. Please contact us so we can fix the problem.

To show this message when appropriate, revise the SearchErrorMessage component as follows:

export const SearchErrorMessage = ({status}) => {
  return status === '' || status === 'OK' ? null : (
    <div role="alert">
      {
        status === 'ZERO_RESULTS' || status === 'INVALID_REQUEST' || status === 'NOT_FOUND' ? (
          <p>
            No place is found on the map. Try another search term.
          </p>
        {/* ADDED FROM HERE */}
        ) : status === 'OVER_QUERY_LIMIT' || status === 'REQUEST_DENIED' ? (
          <p>
            My Ideal Map is currently unable to use Google Maps search. Please contact us so we can fix the problem.
          </p>
        {/* ADDED UNTIL HERE */}
        ) : null
      }
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

I need to add a contact form to this message later in the process of making the app.

6.4 Google Maps server is down

The final edge case is when the Google Maps server is not responding to the API call. In this case, the status variable takes the string value of "UNKNOWN_ERROR".

Also, if someone hacks the Google Maps server, the status variable may take other values than the ones specified in the documentation.

So I revise the SearchErrorMessage component as follows:

export const SearchErrorMessage = ({status}) => {
  return status === '' || status === 'OK' ? null : (
    <div role="alert">
      {
        status === 'ZERO_RESULTS' || status === 'INVALID_REQUEST' || status === 'NOT_FOUND' ? (
          <p>
            No place is found on the map. Try another search term.
          </p>
        ) : status === 'OVER_QUERY_LIMIT' || status === 'REQUEST_DENIED' ? (
          <p>
            My Ideal Map is currently unable to use Google Maps search. Please contact us so we can fix the problem.
          </p>
        {/* REVISED FROM HERE */}
        ) : (
          <p>
            Google Maps server is down. <a href="https://status.cloud.google.com/maps-platform/products/i3CZYPyLB1zevsm2AV6M/history" target="_blank" rel="noreferrer">Please check its status</a>, and try again once they fix the problem (usually within a few hours).
          </p>
        )
        {/* REVISED UNTIL HERE */}
      }
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

where the link points to the Google Maps Platform page on incidents reported on Places API. Since it is inconvenient for the user to leave the app to visit an external website, clicking the link text will open the site in a new tab with target="_blank", which should always be coupled with rel="noreferrer" for security reasons (otherwise ESLint complains).

I do not check the statement status === "UNKNOWN_ERROR" in the above code because all the other cases are already handled in the code by the time the browser reaches these lines of code.


I can improve the exact phrase of the messages to appear in the four edge cases described above. I would be happy to hear you in a comment to this article if you have a better idea.

6.5 None of the suggestions is relevant

The last edge case is what I do not know how to handle.

The Google Maps server returns up to five search suggestions in response to the search term (see Google Maps Platform documentation). And there seems no way to increase the upper limit (see a StackOverflow discussion on this matter).

Consequently, none of the five suggestions may be what the user is searching for. The user is likely to expect to see more suggestions by scrolling down. But the Google Maps server doesn’t allow us to implement it in an app.

We could tell the user that we cannot show more than five suggestions, if the user doesn’t do anything for, say, three seconds after five search suggestions get displayed.

But that is an intrusive user experience. The user may find what they are looking for, but just get interrupted by a phone call or something. Showing a message in this case can confuse the user.

Any idea?

7. Styling autocomplete suggestion text

The code written so far will do a decent job to provide the user with the autocomplete search feature of Google Maps.

For an additional improvement of user experience, however, we can highlight the search term that appears in the autocomplete suggestions.

7.1 UI design considerations

Writing about UX design of search suggestions, Moran 2018 recommends visually differentiating those characters already typed by the user and those suggested by the app:

...each suggestion has two parts: the characters already typed by the user, and the characters suggested by the system to complete the query. It’s important to use different visual styles to show which characters fall into each category. You can use bolding, italics, color, or indenting to communicate the differences between these parts. Visual differentiation helps users understand why the suggestions are shown and scan the available options.

A thorny issue is which type of characters to highlight, the characters already typed by the user or those suggested by the system. The same article recommends the following:

If your suggested search feature only appends characters to the end of the user’s text to finish the query, then you should highlight the suggested characters. If, instead, your suggested search feature will suggest popular queries that contain the user’s text anywhere in the query, it’s best to highlight the user’s query.—Moran (2018)

Google Maps’s search falls into the second case: the user’s text is contained anywhere in a suggested place name.

So I want to highlight the user’s text.

7.2 What Google Maps API gives us

Google Maps API returns not only place names and addresses, but also

  • length: the number of characters of the user’s text included in a place name or address
  • offset: the number of characters in a place name or address that precede the user’s text

For example, imagine the user types arashiyama garden, which is 17 characters long (including a space).

One of the autocomplete suggestions will be Bread, Espresso & Arashiyama Garden (one of my favorite cafes in Kyoto). In this case, the user’s text starts after 18 characters (including spaces) of Bread, Espresso &.

So the length is 17, and the offset is 18.

In Section 4.2 above, I gave an example of the object of an autocomplete suggestion returned by Google Maps API. Let me give an excerpt of it:

"structured_formatting" : {
  "main_text" : "Fukuda Art Museum",
  "main_text_matched_substrings" : [
      {
        "length" : 8,
        "offset" : 0
      }
  ],
  "secondary_text" : "3-16 Sagatenryuji Susukinobabacho, Ukyo Ward, Kyoto, Japan",
  "secondary_text_matched_substrings" : [
      {
        "length" : 33,
        "offset" : 0
      }
  ]
},
Enter fullscreen mode Exit fullscreen mode

The length and offset values are given in the main_text_matched_substrings and secondary_text_matched_substrings properties of the structured_formatting property for the place name and its address, respectively.

7.3 Implementation

To extract the length and offset values, let’s revise the handlePredictions function in Section 4.2 as follows:

function handlePredictions(predictions, status) {
  if (status === "OK") {
    const autocompleteSuggestions = predictions.map((prediction) => {
      return {
        id: prediction.place_id,
        name: {
          string: prediction.structured_formatting.main_text,
          // ADDED FROM HERE
          length: prediction.structured_formatting.main_text_matched_substrings[0]['length'],
          offset: prediction.structured_formatting.main_text_matched_substrings[0]['offset'],
          // ADDED UNTIL HERE
        },
        address: {
          string: prediction.structured_formatting.secondary_text,
          // ADDED FROM HERE
          length: prediction.structured_formatting.secondary_text_matched_substrings[0]['length'],
          offset: prediction.structured_formatting.secondary_text_matched_substrings[0]['offset'],
          // ADDED UNTIL HERE
        },
      };
    });
    setSearchResult({
      autocompleteSuggestions: autocompleteSuggestions,
      status: 'OK',
    })
  } else {
    setSearchResult({
      autocompleteSuggestions: [],
      status: status,             
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Given that each autocomplete suggestion takes the form of an object with string, length, and offset properties, I create a custom function to bold the user’s text:

function boldUserText({ length, offset, string }) {
  if (length === 0 && offset === 0) {
    return string;
  }
  const userText = string.substring(offset, offset + length);
  const stringBefore = string.substring(0, offset);
  const stringAfter = string.substring(offset + length);
  return `${stringBefore}<b>${userText}</b>${stringAfter}`;
}
Enter fullscreen mode Exit fullscreen mode

If both length and offset are 0, then there is no user’s text. So just return the original string. Otherwise, extract the user’s text, the text before, and the text after by using the .substring() string method of JavaScript along with the length and offset values. Finally, combine the three with the user’s text wrapped with the <b> tag. This is what boldUserText() returns.

The .substring() method works as follows. When it takes two arguments, a and b, it removes the first a characters, keeps the subsequent b characters, and drop the rest. When it takes only one argument, say, c, it removes the first c characters and keep the rest (see MDN Web Docs for detail).

I use <b> to bold user’s text. According to the HTML Living Standard:

The b element represents a span of text to which attention is being drawn for utilitarian purposes without conveying any extra importance and with no implication of an alternate voice or mood, such as key words in a document abstract, product names in a review, actionable words in interactive text-driven software, or an article lede.—HTML Living Standard, section 4.5.21

In our case, the bold text is meant to draw the user’s attention so that they will know which part of autocomplete suggestions includes the text they have entered in the search box. That’s exactly what the <b> element stands for.

It shouldn’t be <strong>, because:

The strong element represents strong importance, seriousness, or urgency for its contents.—HTML Living Standard, section 4.5.3

In our case, the bold text does not convey any extra significance.

Now, how should we use the boldUserText function to render HTML? Given that the returned value from this helper function is not string but JSX (i.e., containing HTML tags), we cannot code like this:

// DOES NOT WORK!
{
  searchResult.autocompleteSuggestions.length > 0
  ? searchResult.autocompleteSuggestions.map((item, index) => {
      return (
        <li
          key={item.id}
          {...getItemProps({
            item,
            index
          })}
        >
          <p>{boldUserText(item.name)}</p>
          <p>{boldUserText(item.address)}</p>
        </li>
      );
    })
  : null
}
Enter fullscreen mode Exit fullscreen mode

Instead, we need to use the React's prop called dangerouslySetInnerHTML (see React docs):

{
  searchResult.autocompleteSuggestions.length > 0
    ? searchResult.autocompleteSuggestions.map((item, index) => {
      return (
        <li key={item.id} {...getItemProps({ item, index })}>
          <p dangerouslySetInnerHTML={{__html: boldUserText(item.name)}}>
          <p dangerouslySetInnerHTML={{__html: boldUserText(item.address)}}>
        </li>
      );
    });
}
Enter fullscreen mode Exit fullscreen mode

Now each autocomplete suggestion highlights the search term that the user has entered in the search box!
Characters The search term "fushim" is highlighted in bold (a screenshot of My Ideal Map in development)

As you see in the above screenshot, a few parts of the addresses are also highlighted even though they don’t contain the search term. This is Google Maps’s fault. The offset and length values do not necessarily reflect where the search term is included. It is a bug that I need to fix in the future, but not critical enough for a minimum viable product.

8. Why Demo is unavailable

I wish I could give you the link to the demo of the entire code described in this article. However, Google charges me for accessing to Google Maps API, as described in Section 3.1 above.

Sorry.

9. Next up

The next step is to show on the embedded Google Maps the place the user selects out of the autocomplete suggestions rendered in this article.

Here you go: Day 26 of this blog series

References

Dan Abramov (2017) “As mentioned in the user guide, you need to explicitly read any global variables from window...”, Stack Overflow, May 1, 2017.

Dodds, Kent C. (2017) “Use the key prop when Rendering a List with React”, The Beginner’s Guide to React V1, 2017.

Imoh, Chinedu. (2022) “Integrating Google Places Autocomplete API in a React App”, Progress Telerik Blogs, Jun 29, 2022.

Kassym, Gapur. (2020) “How to Use Google Place Autocomplete With React Without a Third-Party Library”, Better Programming, Feb 3, 2020.

Kudamatsu, Masa. (2021) “4 gotchas when setting up Google Maps API with Next.js and ESLint”, Web Dev Survey from Kyoto, Feb 12, 2021.

MDN Contributors. (2023) “Functions”, MDN Web Docs, Jan 31, 2023 (last updated).

Whitenton, Kathryn. (2014) “3 Guidelines for Search Engine "No Results" Pages”, Nielsen Norman Group, Jan 5, 2014.

Whiting, Jon. (2017) “To ARIA! The Cause of, and Solution to, All Our Accessibility Problems”, WebAIM, May 17, 2017.

Top comments (0)