Integrating Google Maps in React

Integrating maps into your app is a highly-demanded feature, especially taking into account the COVID-19 pandemic situation. Like never before, we tend to make online orders even when it comes to ordinary things such as food, clothes, all sorts of goods from supermarkets, etc. And thus, we are generally interested in tracking such things down or looking for a location of a particular place/item of our interest. That is why many of our clients are requesting a map integration in their products.

API key

Obviously, the first map that crosses everyone’s mind is Google Maps, the most frequently used map application on the planet so far. In addition, it is not that pricy and the free $300 bonus you get after registering your app can be enough for quite a long time for most businesses, especially at an early stage. In order to be able to use Google Maps API, it is essential that you create your API key and enable 3 APIs in the Google Cloud console that will be used when implementing our interactive maps: Maps JavaScript API, Geocoding API, and Places API.

Bearing in mind that you have followed the instructions and successfully set the above-mentioned APIs and got your API key, I would like to clarify what our project is going to be about. The main motivation for writing this article lies in the fact that when I got a task to create and style a map in our React project that had to be somewhat similar to the one Airbnb has, I was not able to find any good instructions to follow that would cover at least 50% of the functionality I needed. That is why, having struggled for a couple of days, I decided to share some ideas that can greatly simplify your life and save a lot of precious time.

What we are going to implement

The main goal is to implement a map with an info window that opens when clicking on a marker. We will also implement driving directions and create a scrollable item list just like the one on Airbnb. When a particular item from the list is hovered on, the corresponding marker will change its state from inactive to active and directions from that marker to 2 other places on the map will be displayed. The country we are going to deal with is my favourite one - Sweden. We will have 3 markers (list items) representing 3 popular Swedish cities - Stockholm, Uppsala, and Västerås. When these are clicked on, a relevant InfoWindow component is activated along with the directions to 2 of my most favourite Swedish cities I lived in - Falun and Borlange.

Choosing a library

First of all, there is a necessity to choose a proper library that can be used for our task. Interestingly, the most popular library with the biggest number of weekly downloads is the one that can hardly boast about being maintained - react-google-maps. However, this is the only map that has decent documentation and most of the other maps are basically rewrites of this library and have either the same or very similar API. In addition, all the functionality we need can be implemented by its means and I personally had no trouble using it when developing the maps for this project. As a footnote, there is a good alternative - @react-google-maps/api that is believed to be a complete rewrite of react-google-maps and is still maintained, while having a similar API. So, this project uses react-google-maps and, of course, changes the examples from its documentation to function components using React hooks.

It is also important to mention that I use styled-components and styled-system, so, please, make sure you are familiar with its syntax and layout principles so that you have no trouble understanding the code. In addition, this project is built with Gatsby, but you can create react app with any tool you like.

Setting data, constants and making first steps

Let’s install react-google-maps by writing this in your terminal inside your project folder:

npm i react-google-maps

Now, let’s create some mock data that we are going to use in our project:

export const MOCK_DESTINATIONS_DATA = [
  {
    id: 1,
    title: 'Falun',
    coordinates: { lat: 60.610361157011646, lon: 15.63610136902125 },
    src:
      'https://i.pinimg.com/originals/09/9e/60/099e600bcfa057bf1c9ecdcce0ad529c.jpg',
  },
]
export const MOCK_ORIGINS_DATA = [
  {
    id: 2,
    title: 'Uppsala',
    coordinates: { lat: 59.86700302991904, lon: 17.639901897585162 },
    src:
      'https://d3aux7tjp119y2.cloudfront.net/images/oscar-ekholm-DC50Oj2m4CY-unsplash-CMSTemplate.width-1650_5AWSVQc.jpg',
  },
]

As you can see, we are going to pass 2 arrays of objects to our Map component - destinations and origins, carrying all the necessary information for our list items: title, coordinates (latitude and longitude) and src holding a link to each city image. In the example above I used just 2 elements to demonstrate the data. The original data with 5 items in total can be found in my repository.

Now, let's create the main body of our Map component in a Map folder and name it MapContainer. It will then be exported to its index.js file. At the top of the file, along with react and prop-types, we need to import all react-google-maps modules we are going to use:

import {
  Marker,
  GoogleMap,
  // InfoWindow,
  withScriptjs,
  withGoogleMap,
  // DirectionsRenderer,
} from 'react-google-maps'

I commented out the imports that will be used a bit later.

Then, we have to define some important constants that our GoogleMap module requires:

export const MAP_SETTINGS = {
  DEFAULT_MAP_OPTIONS: {
    scrollwheel: false,
    mapTypeControl: false,
    fullscreenControl: false,
    streetViewControl: false,
  },
  DEFAULT_CENTER: { lat: 57, lng: 20 },
  DEFAULT_ZOOM: 4,
  MARKER_SIZE: {
    EXTRA_SMALL: 10,
    SMALL: 30,
    MEDIUM: 40,
  },
  PIXEL_OFFSET: {
    MARKER: {
      X: 0,
      Y: -35,
    },
  },
  DIRECTIONS_OPTIONS: { suppressMarkers: true, preserveViewport: true },
}

You can find these constants in src/constants/constants.js. These constants are then passed to the GoogleMap and solve a number of issues for us. Let's look closely at each key-value pair:

  • scrollwheel: false, - disables zooming the map on scrolling over the map.
  • mapTypeControl: false, - disables Map/Satellite buttons in case you don't need a Satellite view
  • fullscreenControl: false, - hides default full-screen buttom in case you need a cleaner design and you don't need that functionality
  • streetViewControl: false, - disables the Pegman icon that can be dragged onto the map to enable Street View
  • DEFAULT_CENTER, - an object with default latitude and longitude coordinates that will be shown by default in case there is no data/data is loading and was not yet fetched.
  • DEFAULT_ZOOM, - integer
  • MARKER_SIZE, - an integer representing your marker size in pixels
  • PIXEL_OFFSET.MARKER.X, - X-axis offset of the InfoWindow component in pixels
  • PIXEL_OFFSET.MARKER.Y, - Y-axis offset of the InfoWindow component in pixels
  • suppressMarkers: true, - hide default markers
  • preserveViewport: true, - disable zoom on direction display (when a direction is calculated the map is zoomed to fit the screen boundaries)

Let's import our constants into the MapContainer.js and comment the ones that will be used a bit later:

import { MAP_SETTINGS } from 'constants/constants' 

const {
  MARKER_SIZE,
  DEFAULT_ZOOM,
  DEFAULT_CENTER,
  DEFAULT_MAP_OPTIONS,
  // PIXEL_OFFSET,
  // DIRECTIONS_OPTIONS,
} = MAP_SETTINGS

Another important thing that is likely to cross your mind is the customization capabilities of the map. Luckily, you can easily set different color themes, display and hide elements - route types, titles, etc. One of the best resources allowing us to make our customizations is snazzymaps where you can use some of the preset themes or create your own one. When you're happy with the result, just take the JSON and pass it as a style prop inside the defaultOptions parameter of the GoogleMap module (see the snippet below). Here is a link to the mapStyles.json file in the project repository.

import mapStyles from './mapStyles.json' 

const MapContainer = ({ origins, destinations }) => {
  const mapRef = React.useRef(null)
  return (
    <GoogleMap
      ref={mapRef}
      defaultZoom={DEFAULT_ZOOM}
      defaultCenter={DEFAULT_CENTER}
      defaultOptions={{ ...DEFAULT_MAP_OPTIONS, styles: mapStyles }}
    >
      {origins.map(({ coordinates: { lat, lon: lng }, id }) => (
        <Marker
          key={id}
          position={{ lat, lng }}
          icon={{
            url: infoIconInactive,
            scaledSize: new window.google.maps.Size(
              MARKER_SIZE.SMALL,
              MARKER_SIZE.SMALL
            ),
          }}
        />
      ))}
      {destinations.map(({ coordinates: { lat, lon: lng }, id }) => (
        <Marker
          key={id}
          position={{ lat, lng }}
          icon={{
            url: heartIcon,
            scaledSize: new window.google.maps.Size(
              MARKER_SIZE.SMALL,
              MARKER_SIZE.SMALL
            ),
          }}
        />
      ))}
    </GoogleMap>
  )
}

As we can see in the code snippet above, our MapContainer gets 2 properties - origins and destinations. These represent the mock data we declared at the beginning - MOCK_DESTINATIONS_DATA and MOCK_ORIGINS_DATA. It is also necessary to pass mapRef to the GoogleMap module - we create it via a React.useRef hook. Then, we pass our map constants as defaultZoom, defaultCenter, and defaultOptions (make sure you pass the DEFAULT_MAP_OPTIONS as well as mapStyles in case you want to apply a custom map theme).

In order to create markers, we need to map through the Marker module and pass the result as children to the GoogleMap. First of all, we map the Markers representing origins - the 3 Swedish cities I mentioned above: Stockholm, Uppsala, and Västerås. These will be displayed as info icons. Then we map the destinations - my top favourite Swedish cities: Falun and Borlange. They will be displayed as heart icons. The Marker module takes 2 main parameters: position, representing your marker's coordinates (passed as lat, lng), and icon: here we can pass an object where url is the icon (png, svg, url) we want to display for our group of markers while scaledSize is the size of those icons. As you can see, we need to use new window.google.maps.Size that accepts 2 integers (icon width and height) and subsequently converts them so that the Icon object used in the background of the Marker module can process them correctly.

Changing native CSS rules

Now, let's create the index.js file in the Map folder and paste the following code there:

import React from 'react'
import styled from 'styled-components'
import { RADIUS } from 'Theme'
import { GOOGLE_MAP_URL } from 'constants/constants'
import { Box } from 'components/Layout'
import MapContainer from './Map'

const StyledBox = styled(Box)`
  position: sticky;
  top: 0;
  height: 100vh;
`

const MapElement = styled(Box)`
  .gm-ui-hover-effect {
    display: none !important;
  }
  .gm-style .gm-style-iw-t::after {
    box-shadow: -2px 2px 2px rgba(66, 149, 165, 0.25);
  }
  .gm-style-iw.gm-style-iw-c {
    padding: 0;
    .gm-style-iw-d {
      overflow: hidden !important;
    }
  }
  .gmnoprint.gm-bundled-control.gm-bundled-control-on-bottom {
    top: 0;
  }
`

const Map = (props) => (
  <StyledBox>
    <MapContainer
      googleMapURL={GOOGLE_MAP_URL}
      loadingElement={<Box height="100%" />}
      containerElement={<Box height="100%" />}
      mapElement={<MapElement height="100%" />}
      {...props}
    />
  </StyledBox>
)

export default Map

As you can see, we import GOOGLE_MAP_URL:

 `https://maps.googleapis.com/maps/api/js?v=3.exp&libraries=geometry,drawing,places&key=${process.env.GATSBY_GOOGLE_KEY}`

where GATSBY_GOOGLE_KEY is the API key that we created at the beginning of the article.

In addition, apart from the GOOGLE_MAP_URL we need to pass 3 div elements to the map: loadingElement, containerElement and mapElement and set their height to 100%. In this case, you can then set your map height using an outer container and make sure all 3 elements get the same height - the most common case. Setting height explicitly is a crucial step since the default value is 0 and your map won't be displayed at all. Of course, you can set all 3 sizes independently. You can also see that our Map takes props here and passes them to the MapContainer - these are MOCK_DESTINATIONS_DATA and MOCK_ORIGINS_DATA as mentioned above. Another important step that you may want to take is to rewrite some basic styles of the InfoWindow component since its default looks can hardly meet the needs of your project. And, of course, you can add some CSS rules to the Map - in my case, I set position to sticky, top to 0 and height to 100vh. Now, let's have a closer look at the classes we will rewrite in the StyledBox component.

 .gm-ui-hover-effect {
    display: none !important;
  }

This rule removes the close button from the top-right corner of the InfoWindow

  .gm-style .gm-style-iw-t::after {
    box-shadow: -2px 2px 2px rgba(66, 149, 165, 0.25);
  }

This rule applies box-shadow (or you can set any other rules) to the InfoWindow bottom arrow

.gm-style-iw.gm-style-iw-c {
    padding: 0;
    .gm-style-iw-d {
      overflow: hidden !important;
    }
}

This fixes padding of the InfoWindow and enables to properly stretch the image removing the left and top offsets that damage the whole layout

 .gmnoprint.gm-bundled-control.gm-bundled-control-on-bottom {
    top: 0;
  }

This rule places the native zoom buttons at the bottom-right corner - in case you need that

Setting zoom

When we import our Map component in the Home.js file and use it there we should see the working map with 5 Markers:

import React from 'react'
import Map from './Map'
import { MOCK_ORIGINS_DATA, MOCK_DESTINATIONS_DATA } from './data'

const Home = () => (
  <Map origins={MOCK_ORIGINS_DATA} destinations={MOCK_DESTINATIONS_DATA} />
)

export default Home

As you can see, we immediately stumble into a problem: the zoom we set by default is too small and the icons we added resemble an unpleasant mess since they are placed on top of one another. We could use a different zoom value, but in case we want to display the icons in different countries around Europe or add some of them to the US, the higher zoom value will make those icons hidden and that is obviously not what we want. What we should do instead, is make the zoom automatically depend on our icons' position. Let's place this code in the MapContainer component before the return statement:

React.useEffect(() => {
  const bounds = new window.google.maps.LatLngBounds()
  origins.forEach(({ coordinates: { lat, lon } }) => {
    bounds.extend(new window.google.maps.LatLng(lat, lon))
  })
  destinations.forEach(({ coordinates: { lat, lon } }) => {
    bounds.extend(new window.google.maps.LatLng(lat, lon))
  })
  mapRef.current.fitBounds(bounds)
}, [destinations, origins])

The useEffect hook is called in case destinations and origins parameters passed to MapContainer are changed. Here we create the bounds object using new window.google.maps.LatLngBounds(), then we run through the origins and destinations arrays storing our data and call the extend method where we pass latitude and longitude of each marker we want to display on the map. As you can see, we need to pass our markers' latitude and longitude using window.google.maps.LatLng. At the very end, we pass the bounds object to the mapRef.current.fitBounds. This will do the trick and the map will always be zoomed in such a way, that all our markers fit in the Map window.

Adding InfoWindow, Card List and Layout

Now let's create a Card component that we will use in the CardList and a custom InfoWindow component that will be opened when clicking on our markers.

import React from 'react'
import PropTypes from 'prop-types'
import styled from 'styled-components/macro'
import { rem } from 'polished'
import { Text } from 'components/Typography'
import { Box } from 'components/Layout'
import { COLOR, SPACE, FONT_SIZE, FONT_WEIGHT } from 'Theme'

const MAX_WIDTH = rem(240)

const LocationImage = styled('img')`
  object-fit: cover;
  width: 100%;
  height: ___CSS_0___;
`

const InfoWindow = ({ title, src }) => (
  <Box maxWidth={MAX_WIDTH}>
    <LocationImage src={src} />
    <Box m={SPACE.M}>
      <Text
        fontSize={FONT_SIZE.L}
        fontWeight={FONT_WEIGHT.SEMI_BOLD}
        color={COLOR.BLACK}
      >
        {title}
      </Text>
    </Box>
  </Box>
)

InfoWindow.propTypes = {
  title: PropTypes.string.isRequired,
  src: PropTypes.string.isRequired,
}

export default InfoWindow

As you can see, the InfoWindow component is quite simple: it accepts the title and src from our MOCK_ORIGINS_DATA data and displays an image with fixed height and a title below it. The Card component has the same principle:

import React from 'react'
import PropTypes from 'prop-types'
import styled from 'styled-components'
import { rem } from 'polished'
import { COLOR, SPACE, FONT_SIZE, FONT_WEIGHT } from 'Theme'
import { Text } from 'components/Typography'
import { Box } from 'components/Layout'

const LocationImage = styled('div')`
  height: ___CSS_0___;
  ___CSS_1___
`

const Card = ({ title, src }) => (
  <Box bg={COLOR.LYNX_WHITE} p={SPACE.M} mb={SPACE.M}>
    <LocationImage src={src} />
    <Text
      as="p"
      fontSize={FONT_SIZE.L}
      fontWeight={FONT_WEIGHT.BOLD}
      color={COLOR.BLACK}
      mt={SPACE.M}
    >
      {title}
    </Text>
  </Box>
)

Card.propTypes = {
  src: PropTypes.string.isRequired,
  title: PropTypes.string.isRequired,
}

export default Card

Next up, let's create a Layout component that will help us position the Map and the Card list side by side. This code is not related to the Google Maps logic, but it will clarify the way the layout is organized a bit:

import React from 'react'
import PropTypes from 'prop-types'
import styled from 'styled-components'
import { rem } from 'polished'
import { SPACE } from 'Theme'
import { Flex } from 'components/Layout'

const ListWrapper = styled('div')`
  flex: 1;
  min-width: 0;
  max-width: ___CSS_0___;
`

const List = styled('div')`
  width: 100%;
  margin-left: auto;
  padding: 0 ___CSS_0___ ___CSS_1___ ___CSS_2___;
  max-width: ___CSS_3___;
`

const Map = styled('aside')`
  flex: 1;
`

const Layout = ({ mapContent, listContent, contentWidth }) => (
  <Flex>
    <ListWrapper contentWidth={contentWidth}>
      {React.cloneElement(listContent, { contentWidth })}
    </ListWrapper>
    {mapContent}
  </Flex>
)

Layout.defaultProps = {
  contentWidth: rem('675px'),
}

Layout.propTypes = {
  contentWidth: PropTypes.string,
  mapContent: PropTypes.node.isRequired,
  listContent: PropTypes.node.isRequired,
}

Layout.List = List
Layout.Map = Map

export default Layout

Now let's remove the Map component from Home.js and abstract that code along with the Card list and the newly created Layout in ListMapSection.js file:

import React from 'react'
import Map from './Map'
import Card from './Card'
import Layout from './Layout'
import { MOCK_ORIGINS_DATA, MOCK_DESTINATIONS_DATA } from './data'

const ListMapSection = () => {
  const [hoveredOriginId, setHoveredOriginId] = React.useState(null)
  return (
    <Layout
      listContent={
        <Layout.List>
          {MOCK_ORIGINS_DATA.map((originData) => (
            <Card
              key={originData.id}
              onHover={setHoveredOriginId}
              {...originData}
            />
          ))}
        </Layout.List>
      }
      mapContent={
        <Layout.Map>
          <Map
            origins={MOCK_ORIGINS_DATA}
            destinations={MOCK_DESTINATIONS_DATA}
            hoveredOriginId={hoveredOriginId}
          />
        </Layout.Map>
      }
    />
  )
}

export default ListMapSection

In the code snippet above, we have also added a React.useState hook - this is going to be used for storing the card id that was hovered on. As you remember, we have 2 groups of markers - origins and destinations, where origins are also represented as Cards in the Card list. That means that both Cards and origin Markers (they display 3 Swedish cities - Stockholm, Uppsala and Västerås) get the same IDs from our MOCK_ORIGINS_DATA. Now we need to use setHoveredOriginId when one of the Cards is hovered and pass it to the Map component - this information is needed in order to change the corresponding marker state on hovering over the Card and thus indicating the relation between the Marker on the Map and the Card in the Card List. In other words, we should make sure that when the Stockholm card is hovered on, the Stockholm marker on the map changes its color so that you can immediately see where Stockholm is on the map. That is why we pass setHoveredOriginId as onHover to the Card component and the hoveredOriginId to the Map.

Add debounce, outside click handler and modifying components

Let us modify the Card component first:

import { useDebouncedCallback } from 'use-debounce'

const DURATION = 400

const Card = ({ id, title, onHover, src }) => {
  const debounced = useDebouncedCallback((value) => {
    onHover(value)
  }, DURATION)
  return (
    <Box
      onMouseEnter={() => debounced.callback(id)}
      onMouseLeave={() => debounced.callback(null)}
      bg={COLOR.LYNX_WHITE}
      p={SPACE.M}
      mb={SPACE.M}
    >
      <LocationImage src={src} />
      <Text
        as="p"
        fontSize={FONT_SIZE.L}
        fontWeight={FONT_WEIGHT.BOLD}
        color={COLOR.BLACK}
        mt={SPACE.M}
      >
        {title}
      </Text>
    </Box>
  )
}

At the very top of the snippet we have imported the use-debounce module. You can install it by typing npm i use-debounce in your console, being in the project folder. This is one of the crucial moments that will greatly help when implementing Directions API. Let's add it here right away. It does a very important trick - it ensures that onMouseEnter and onMouseLeave events are not triggered too frequently, and thus, if the user starts hovering over the cards at a high speed for some reason, these events are not triggered every time the hovering happens. This is very important for the Directions implementation, since every time you hover over the cards or click on the markers, a request will be performed and Google's API will limit the rate of requests in order to reduce the workload, and your application will start receiving errors. It's very important to bear this in mind. Since we are going to display directions on card hover and on marker select, using useDebouncedCallback is a must. If you don't need to implement Directions API in your project, just skip this step and set the state without the debounced.callback:

   <Box
      onMouseEnter={() => onHover(id)}
      onMouseLeave={() => onHover(null)}
     ...

You might have also noticed that onMouseLeave callback sets the value of the state to null. This action is needed in order to remove marker selection and displayed direction if no card is hovered on.

Now, let's add our InfoWindow component that has the following functionality:

  • to pop up when origin markers are clicked on
  • to get removed if a particular card from the card list is hovered on
  • to have click outside logic, that closes the InfoWindow if there is a click outside of its boundaries
  • to disable click outside functionality when dragging the map so that we can adjust the map canvas without closing the InfoWindow component
  • to change marker state when clicked to active and set it back to inactive on card hover.
import OutsideClickHandler from 'react-outside-click-handler' 

const MapContainer = ({ origins, destinations, hoveredOriginId }) => {
  const mapRef = React.useRef(null)
  const [selectedOriginId, setSelectedOriginId] = React.useState(null)
  const selectedOrHoveredOriginId = hoveredOriginId || selectedOriginId
  const selectedOrigin = origins.find(({ id }) => selectedOriginId === id)
  const [isClickOutsideDisabled, setIsClickOutsideDisabled] = React.useState(
    false
  )
  React.useEffect(() => {
    const bounds = new window.google.maps.LatLngBounds()
    origins.forEach(({ coordinates: { lat, lon } }) => {
      bounds.extend(new window.google.maps.LatLng(lat, lon))
    })
    destinations.forEach(({ coordinates: { lat, lon } }) => {
      bounds.extend(new window.google.maps.LatLng(lat, lon))
    })
    mapRef.current.fitBounds(bounds)
  }, [destinations, origins])
  React.useEffect(() => {
    if (hoveredOriginId) {
      setSelectedOriginId(null)
    }
  }, [hoveredOriginId])
  return (
    <GoogleMap
      ref={mapRef}
      defaultZoom={DEFAULT_ZOOM}
      defaultCenter={DEFAULT_CENTER}
      defaultOptions={{ ...DEFAULT_MAP_OPTIONS, styles: mapStyles }}
      onDragStart={() => setIsClickOutsideDisabled(true)}
      onDragEnd={() => setIsClickOutsideDisabled(false)}
    >
      {origins.map(({ coordinates: { lat, lon: lng }, id }) => (
        <Marker
          key={id}
          position={{ lat, lng }}
          icon={{
            url:
              id === selectedOrHoveredOriginId
                ? infoIconActive
                : infoIconInactive,
            scaledSize: new window.google.maps.Size(
              MARKER_SIZE.SMALL,
              MARKER_SIZE.SMALL
            ),
          }}
          onClick={() => {
            setSelectedOriginId(id)
          }}
        />
      ))}
      {destinations.map(({ coordinates: { lat, lon: lng }, id }) => (
        <Marker
          key={id}
          position={{ lat, lng }}
          icon={{
            url: heartIcon,
            scaledSize: new window.google.maps.Size(
              MARKER_SIZE.SMALL,
              MARKER_SIZE.SMALL
            ),
          }}
        />
      ))}
      {selectedOrigin && (
        <InfoWindow
          position={{
            lat: selectedOrigin.coordinates.lat,
            lng: selectedOrigin.coordinates.lon,
          }}
          options={{
            pixelOffset: new window.google.maps.Size(
              PIXEL_OFFSET.MARKER.X,
              PIXEL_OFFSET.MARKER.Y
            ),
          }}
        >
          <OutsideClickHandler
            onOutsideClick={() => {
              setSelectedOriginId(null)
            }}
            disabled={isClickOutsideDisabled}
          >
            <InfoWindowContent {...selectedOrigin} />
          </OutsideClickHandler>
        </InfoWindow>
      )}
    </GoogleMap>
  )
}

Let's install react-outside-click-handler just the way we installed the rest of the packages - write npm i react-outside-click-handler in your terminal opened in the folder of your project. Then we need to declare a useState hook for saving ID of the currently selected/clicked marker - selectedOriginId. We can now set this id via setSelectedOriginId that we can pass as onClick callback to the Marker component as shown above. I also declared selectedOrHoveredOriginId constant that can have either the value of hoveredOriginId that comes as a prop from our Cards List or the value stored in selectedOriginId. Then selectedOrHoveredOriginId is used in the Mark component in order to define whether the icon should be active or inactive:

 icon={{
    url:
      id === selectedOrHoveredOriginId
       ? infoIconActive
       : infoIconInactive,
    ),
}}

In order to make the currently selected Marker inactive, as well as hide the InfoWindow we can add the following React.useEffect hook that is triggered every time hoveredOriginId exists (when the card is hovered) and selectedOriginId is set to null.

 React.useEffect(() => {
    if (hoveredOriginId) {
      setSelectedOriginId(null)
    }
  }, [hoveredOriginId])

In order to display the InfoWindow component we need to get the data this component requires - latitude and longitude:

 const selectedOrigin = origins.find(({ id }) => selectedOriginId === id)

In the line above, we get an object from origins according to the selected ID and based on that data, we can now display the InfoWindow component that is also passed as children to the GoogleMap:

{selectedOrigin && (
    <InfoWindow
      position={{
        lat: selectedOrigin.coordinates.lat,
        lng: selectedOrigin.coordinates.lon,
      }}
      options={{
        pixelOffset: new window.google.maps.Size(
            PIXEL_OFFSET.MARKER.X,
            PIXEL_OFFSET.MARKER.Y
        ),
      }}
    >
        <OutsideClickHandler
          onOutsideClick={() => {
                setSelectedOriginId(null)
           }}
          disabled={isClickOutsideDisabled}
        >
      <InfoWindowContent {...selectedOrigin} />
    </OutsideClickHandler>
 </InfoWindow>
)}

As we can see above, we pass an object with lat, lng keys to the position parameter, and we can also define the vertical and horizontal offsets of the InfoWindow component in relation to the Marker - this is done by passing the pixelOffset to options parameter using new window.google.maps.Size that takes 2 integers - X and Y offsets. We then pass our InfoWindowContent component that will display the image of the selected city and its title while wrapping it with our newly installed OutsideClickHandler as children. The handler accepts 2 parameters - onOutsideClick callback where we set selectedOriginId to null and thus close the InfoWindow and disabled - a boolean to indicate where we need to disable our OutsideClickHandler handler. In our case, we are using isClickOutsideDisabled state that we declared above. Its setIsClickOutsideDisabled is now used in onDragStart and onDragEnd callbacks in order to differentiate between clicking on the map and dragging its canvas. Now, when we drag the map we set isClickOutsideDisabled to true and thus the InfoWindow component is not removed by OutsideClickHandler - and this is precisely the behavior that we need and are used to.

Adding Directions service

The final step of this project is adding Directions service. This API enables us to show directions from one point on the map to another. We can choose 4 modes - driving, walking, cycling, and transit. Keep in mind that flights are not included in this list and must be handled separately using geodesic polylines - a topic for another article. Implementing directions has a quite simple API but can become a challenge when you need to show multiple directions at a time. As mentioned above when dealing with directions requests, Google has restrictions on a number of requests at a time. In other words, having tested this properly, it turns out that the most stable way to request multiple directions is by performing requests in a loop with a timeout of 300 milliseconds. Moreover, you definitely want to make sure the user doesn't request the same directions in succession - this can be achieved by caching. And, of course, we must use debounce (this we have already implemented in the Card component) to make sure the user can't trigger requests too often. If at least one of these points is not taken care of, there is a high likelihood of getting request errors. Furthermore, in such cases, you will have to wait for about 30 seconds until you can start making requests again. You can read more about rate limits and OVER_QEURY_LIMIT in this official documentation.

So, let's write a directionsRequest function:

const directionsRequest = ({ DirectionsService, origin, destination }) =>
  new Promise((resolve, reject) => {
    DirectionsService.route(
      {
        origin: new window.google.maps.LatLng(origin.lat, origin.lon),
        destination: new window.google.maps.LatLng(
          destination.lat,
          destination.lon
        ),
        travelMode: window.google.maps.TravelMode.DRIVING,
      },
      (result, status) => {
        if (status === window.google.maps.DirectionsStatus.OK) {
          resolve(result)
        } else {
          reject(status)
        }
      }
    )
  })

DirectionsService module will be called and passed from the outside:

 const DirectionsService = new window.google.maps.DirectionsService()

You can also see that aside from DirectionsService, the function accepts 2 more parameters - origin and destination. They hold the latitude and longitude values that we need to pass to DirectionsService.route using new window.google.maps.LatLng. And, of course, we need to indicate the travel mode - this is done by using a constant window.google.maps.TravelMode.DRIVING. Finally we can perform our resolve / reject logic by checking the response status - window.google.maps.DirectionsStatus.OK.

Then we need to create a delay function that we will use in a loop for every directions request to make sure they are not made too often and we don't get errors from Google:

const delay = (time) =>
  new Promise((resolve) => {
    setTimeout(() => {
      resolve()
    }, time)
  })

The next step is using a React.useState hook to create a state for storing our directions results - this state actually represents data caching. It will be used every time the user clicks on the marker or hovers over a card to check whether we have the result cached. If yes, no requests will be performed and the cached result will be used for showing directions. Let's initialize the state with an empty object.

const [directions, setDirections] = React.useState({})

And here is the main logic for requesting directions:

const directionsToSelectedOrHoveredOrigin =
    directions[selectedOrHoveredOriginId]
  React.useEffect(() => {
    if (selectedOrHoveredOriginId && !directionsToSelectedOrHoveredOrigin) {
      const DirectionsService = new window.google.maps.DirectionsService()
      const fetchDirections = async () => {
        const selectedOrHoveredOrigin = origins.find(
          ({ id }) => selectedOrHoveredOriginId === id
        )
        const tempDirectionsToOrigin = []
        for (const destination of destinations) {
          const direction = await directionsRequest({
            DirectionsService,
            origin: {
              lat: selectedOrHoveredOrigin.coordinates.lat,
              lon: selectedOrHoveredOrigin.coordinates.lon,
            },
            destination: {
              lat: destination.coordinates.lat,
              lon: destination.coordinates.lon,
            },
          })
          await delay(300)
          tempDirectionsToOrigin.push(direction)
        }
        setDirections((prevState) => ({
          ...prevState,
          [selectedOrHoveredOriginId]: tempDirectionsToOrigin,
        }))
      }
      fetchDirections()
    }
  }, [
    destinations,
    directionsToSelectedOrHoveredOrigin,
    selectedOrHoveredOriginId,
    origins,
  ])

In this snippet, we declare the const directionsToSelectedOrHoveredOrigin that checks whether we have our cached directions for the current selectedOrHoveredOriginId. If we don't, the logic in the useEffect is performed and new directions are requested. As you can remember, we are aiming at showing directions from an origin marker to destinations in this project. In our case we always have 2 destinations - Falun and Borlange. Hence we need to get the selectedOrHoveredOrigin object with all the necessary data and then iterate in the directions array where we call the previously mentioned directionsRequest and pass in DirectionsService, latitude and longitude from both selectedOrHoveredOrigin and our destinations. After each call of directionsRequest, we use our 300ms delay, push the result to the tempDirectionsToOrigin and once we get all the results for both of our destinations, we use setDirections in order to cache our newly received directions.

The final step is to use the DirectionsRenderer module from react-google-maps - we need to map it and pass as children to GoogleMap:

{directionsToSelectedOrHoveredOrigin &&
  directionsToSelectedOrHoveredOrigin.map((direction, i) => (
    <DirectionsRenderer
      key={i}
      directions={direction}
      options={DIRECTIONS_OPTIONS}
    />
  ))}

Now we can uncomment the last constant - DIRECTIONS_OPTIONS (see the beginning of the article) and pass it to options.

Summary

Having gotten some experience with React Google Maps I came to realize that we are actually presented with a great tool but with minimum preset functionality. Most of the things we expect to be solved for us have to be set manually. If you are working on a client project, you will definitely be required to style your InfoWindow component because its basic styling can hardly meet your expectations, and consequently, you will have to use native classes to rewrite the styles the way you need to. Moreover, you will need to solve click outside functionality, map dragging, proper zoom, and even set a proper distance between your InfoWindow and its marker. Most of the basic behaviour and looks have to be changed and tweaked. The whole situation gets even worse because of the absence of detailed documentation that can guide you through the whole process. That is why one of the key points of the current article is to pinpoint those bits for you and show how they can be changed. Another valuable solution is related to the Directions Service implementation especially when it has to work with several directions at a time. Of course, these are not the tools we are limited to - in the next article we will build a map with geodesic polylines to imitate flight trajectories and create other useful things. But for now, you are all set and ready to create a clone of Airbnb.

written by

Pavlo Chabanenko

Let’s build something that users love!

Contact us
hello@sudolabs.io

DUETT Business Residence
Námestie osloboditeľov 3/A
040 01 Košice, Slovakia

©2021 Sudolabs