TL;DR
To create an user experience with Google Maps place search like this:
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} />
</>
)
};
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}`;
}
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>
)
}
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} />
);
}
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
});
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();
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();
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],
);
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,
);
where
-
service
is an instance ofAutocompleteService
class (see Section 2.3 above) -
input
specifies the search term entered by the user -
sessionToken
is the one generated withAutocompleteSessionToken()
(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) {}
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
}
}
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" ]
}
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
}
}
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: '',
});
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
}
}
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
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';
Then, specify the array of autocomplete suggestions as the items
option:
useCombobox({
items: searchResult.autocompleteSuggestions,
})
The useCombobox
hook returns what they call prop getters:
const {
getInputProps,
getItemProps,
getMenuProps,
} = useCombobox({
items: searchResult.autocompleteSuggestions,
})
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>
</>
)
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
})
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>
</>
)
};
With this code snippet, we can freely style the list of autocomplete suggestions like this:
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
}
}
});
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:
- Clearly explain that there are no matching results.
- Offer starting points for moving forward.
- 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`
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>
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>
)
}
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>
)
}
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 */}
</>
);
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>
)
}
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>
)
}
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
}
]
},
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,
});
}
}
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}`;
}
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
}
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>
);
});
}
Now each autocomplete suggestion highlights the search term that the user has entered in the search box!
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)