Creating a Google Map component in Rails-React

rhysmalyon

Rhys Malyon

Posted on November 12, 2021

Creating a Google Map component in Rails-React

Table of Contents


  1. React Google Maps API
  2. Creating a Map component
  3. Building our controller logic
  4. Making markers for our places
  5. Adding infowindows to markers
  6. Next steps
  7. Get in touch

1. React Google Maps API


Let's jump straight into building out our map. To start with we'll need to install React Google Maps API, a React package that gives us a handy wrapper around the Google Maps API that gives us a range of pre-built components that we can customise to our needs.

I'll be using npm for my installation but feel free to use yarn if that's what you're more comfortable with. We'll be following the instructions from the package documentation so head to your terminal and enter:

npm install --save @react-google-maps/api

# or

yarn add @react-google-maps/api
Enter fullscreen mode Exit fullscreen mode

2. Creating a Map component


Once the install is complete we're going to create our Map component. In your terminal type:

rails g react:component Map
Enter fullscreen mode Exit fullscreen mode

This will work exactly the same as the HelloWorld component we created previously, creating a new file for us in the rails-react-google-maps/app/javascript/components/ folder. We'll be using the functional component provided in the docs so in your new Map.js component file, delete all the contents and copy in the following setup:

import React from 'react'
import { GoogleMap } from '@react-google-maps/api';

const containerStyle = {
  width: '100vw',
  height: '50vh'
};

const center = {
  lat: -3.745,
  lng: -38.523
};

const Map = () => {
  return (
    <GoogleMap
      mapContainerStyle={containerStyle}
      center={center}
      zoom={10}
    >
      { /* Child components, such as markers, info windows, etc. */ }
      <></>
    </GoogleMap>
  )
}

export default React.memo(Map)
Enter fullscreen mode Exit fullscreen mode

You can technically give the component any name you want but for simplicity's sake we'll stick with Map.

Next let's get our map onto our page! Go back to your index.html.erb file and replace the existing HelloWorld components with the following line (P.S. you can also delete the HelloWorld.js file in your components folder at this point):

<%= react_component("Map") %>
Enter fullscreen mode Exit fullscreen mode

Restart your rails server (Ctrl+C -> rails s) and refresh your localhost:3000 page in your browser. Wait, where's our map?! You should probably see something like this:

Map error

Don't worry, this is a good sign since it means our map component is working! Let's check our browser's developer tools console to see what's happening:

Console errors

We're still missing something: our API keys. We need the keys we generated earlier and put in our .env file in order to gain access to Google Maps.

Back in our application.html.erb view file, paste this line of code inside the body tag. Everything should look like this:

<body>
  <%= yield %>
  <%= javascript_include_tag "https://maps.googleapis.com/maps/api/js?libraries=places&key=#{ENV['GMAPS_BROWSER_KEY']}" %>
</body>
Enter fullscreen mode Exit fullscreen mode

This script will load Google Maps using our browser API key. We're not quite there yet, we still have one more unused key to place somewhere! This time we'll be revisiting our geocoder gem. Go back to rails-react-google-maps/config/initializers/geocoder.rb and uncomment the following lines:

lookup: :nominatim,         # name of geocoding service (symbol)
use_https: false,           # use HTTPS for lookup requests? (if supported)
api_key: nil,               # API key for geocoding service
Enter fullscreen mode Exit fullscreen mode

Next, change the value of each of these to:

lookup: :google,
use_https: true,
api_key: ENV['GMAPS_SERVER_KEY'],
Enter fullscreen mode Exit fullscreen mode

Refresh your localhost:3000 page and you should have a map showing up. It's centered on Brazil for now due to the default coordinates in Map.js but we'll fix that next in the places_controller.rb file.

Google Map


3. Building our controller logic


Our controller is like the brains of the operation, it's connecting our view (and the React components within) with the model and each instance of place. In Rails, the index action is where we access all of the instances of a class. In this case our class is Place and our instances are the 5 locations we seeded earlier (e.g. The White House).


3.1. Showing our places


The first thing we need to do is make sure our index page can read our places. Go back to places_controller.rb and add this line of code in the index action:

def index
  @places = Place.where.not(latitude: nil, longitude: nil)
end
Enter fullscreen mode Exit fullscreen mode

In our view when we want to access our places data we can call @places and it should return every instance. Using .where.not(latitude: nil, longitude: nil) reads almost as we would say it in layman's terms - we only want places where the coordinates are not nil, or empty. If they're empty they'll be excluded from the results since we need coordinates for our map markers.

For some visual feedback, let's quickly add a few simple erb and HTML elements to our index.html.erb page. Underneath the map component, add the following:

<% @places.each do |place| %>
  <h2><%= place.name %></h2>
  <p><%= place.address %></p>
<% end %>
Enter fullscreen mode Exit fullscreen mode

Here we use an each loop to iterate over our places and create an H2 and paragraph with the name and address respectively. It should look something like this:

Map and places


3.2. Sorting our controller logic


We can access whatever information we pass in the index action of our controller. At the moment our default center is sitting over Fortaleza, Brazil but we want to make this dynamic. Thankfully props in React play well with Rails to allow us to pass conditional data which means, depending on how many places we have or where they are, we can change where our map drops us.

In our index.html.erb file where we call our Map component let's add a few properties:

<%= react_component("Map", {
  center: @map_center,
  zoom: @map_zoom,
  markers: [@markers]
}) %>
Enter fullscreen mode Exit fullscreen mode

Of course these properties don't actually exist yet so let's add them to our controller. Let's start with markers. In the React component above we pass them as an array - this is so that we can iterate over them to create individual markers.

Markers

In our controller's index action we'll do some more geocoding:

    @markers = @places.geocoded.map do |place|
      {
        id: place.id,
        lat: place.latitude,
        lng: place.longitude,
        name: place.name,
        address: place.address
      }
    end
Enter fullscreen mode Exit fullscreen mode

The important information we need from here are the coordinates (lat, lng) and the id (for unique keys in React). I've added the name and address for future Google Maps InfoWindow components but we can ignore these for now.

Center and zoom

@map_center and @map_zoom are a little bit more complicated as we'll want these to change based on certain criteria. For example if there are no markers to display, we need a default place to land on. If we have only one place we want our map to center on that spot, and if we have more than one maybe we want a general area encompassing all our places. That's a lot of ifs that we can turn into a conditional:

if @places.count.zero?
  @map_center = [38.9072, 77.0369] # Washington D.C.
  @map_zoom = 0
elsif @places.count == 1
  @map_center = [@places[0].latitude, @places[0].longitude]
  @map_zoom = 14
else
  avg_lat = 0
  avg_lon = 0

  @places.map do |place|
    avg_lat += place.latitude
    avg_lon += place.longitude
  end

  @map_center = [(avg_lat / @places.count), (avg_lon / @places.count)]
  @map_zoom = 12
end
Enter fullscreen mode Exit fullscreen mode

That's a lot to take in so let's break it down into bite-sized pieces:

if @places.count.zero?
  @map_center = [38.9072, 77.0369] # Washington D.C.
  @map_zoom = 0
Enter fullscreen mode Exit fullscreen mode

Here we're saying that if there are no places to add to the map, set our default center to the middle of Washington D.C. I did this because our markers are all based there but you can change these defaults to wherever you like. For zoom, the higher the number, the closer the zoom.

elsif @places.count == 1
  @map_center = [@places[0].latitude, @places[0].longitude]
  @map_zoom = 14
Enter fullscreen mode Exit fullscreen mode

The same idea as above except this time we're checking if there's only one place. If so, we want our map to be centered on that place's coordinates ([@places[0].latitude, @places[0].longitude]) and zoomed in on a closer area.

else
  avg_lat = 0
  avg_lon = 0

  @places.map do |place|
    avg_lat += place.latitude
    avg_lon += place.longitude
  end

  @map_center = [(avg_lat / @places.count), (avg_lon / @places.count)]
  @map_zoom = 12
end
Enter fullscreen mode Exit fullscreen mode

If we have more than one place then we want to show all the markers on the screen. To do this we define two variables (avg_lat and avg_lon) and use these to total up the coordinates of each place. We then divide each one by the number of places we have to give us a middle point.

This solution works for a city-sized area but if you're planning to have places across multiple cities or even countries then using fitbounds() might yield better results. This will require a bit more digging into React as you'll need to tap into the power of hooks (especially useEffect()).


3.3. Adding props to our Map component


Next, go to Map.js in our app/javascript/components folder. Here we need to add the ability for our component to access props. In the function declaration add the following:

const Map = (props) => {
...
Enter fullscreen mode Exit fullscreen mode

By passing props in the declaration we now have access to whatever data we feed the component when it's rendered. In our index.html.erb we provided center, zoom, and markers, so in our component we can access these by simply adding props. before the name. In the body of our Map function, let's make a few changes.

First Let's see what data we're actually working with. Inside the body of our Map component, let's do a quick log to console:

const Map = (props) => {
  console.log(props)
  ...
}
Enter fullscreen mode Exit fullscreen mode

Which will return this in our browser console:

Props console

Think of the parent Object as the props in our props.{data} call. If we call props.center we'll get an array with two elements - our latitude and longitude. They aren't labeled as such but this is what we passed earlier in our controller as @map_center.

We can only access props within the body of the function so let's move our center variable inside and give it some props:

const Map = (props) => {
  const center = {
    lat: props.center[0],
    lng: props.center[1]
  };

  return (
    <GoogleMap
      mapContainerStyle={containerStyle}
      center={center}
      zoom={10}
    >
      { /* Child components, such as markers, info windows, etc. */ }
      <></>
    </GoogleMap>
  )
}
Enter fullscreen mode Exit fullscreen mode

Refresh your page and you should see Washington D.C. (or your place of choice):

Map of Washington D.C. zoom 10

We now have a dynamic center point based on the conditions we set out in our index action! Next, let's set some props for our zoom property:

<GoogleMap
      mapContainerStyle={containerStyle}
      center={center}
      zoom={props.zoom}
    >
Enter fullscreen mode Exit fullscreen mode

Now our map should be more focused on a general area:

Map of Washington D.C. dynamic zoom


4. Making markers for our places


Our map is still missing a key part - markers. We have a general idea of where we are thanks to our new default center and zoom, but we have no idea where to look. Here we're going to make use of the Marker and InfoWindow components provided to us by react-google-maps. Let's start by importing them at the top of our Map component. Where we import GoogleMap, replace that line with the following:

import {
  GoogleMap,
  Marker,
  InfoWindow,
} from '@react-google-maps/api';
Enter fullscreen mode Exit fullscreen mode

Next, inside of our GoogleMap component within the function let's add our markers. If we revisit the data we logged in the console earlier, you'll see that markers were provided as an array:

Markers array

This data comes from @markers in our controller. A powerful way of creating multiple components easily is to use JavaScript's .map method. Inside the GoogleMap component:

<GoogleMap
  mapContainerStyle={containerStyle}
  center={center}
  zoom={props.zoom}
>
  {props.markers[0].map(marker => 
    <Marker
      key={marker.id}
      animation={2}
      position={{
        lat: marker.lat,
        lng: marker.lng,
      }}
    />
  )}
</GoogleMap>
Enter fullscreen mode Exit fullscreen mode

Because markers is an array of arrays, we need to select the element at the first index to map over. From there, we create a new Marker component for each marker element.

When creating multiple components from an array we need to provide React with unique keys to differentiate them. In this case we have a built-in id that will always be unique thanks to the way our PostgreSQL database works. We then provide the position as a JavaScript object in the same way that we created the center variable earlier, only this time it's done in-line.

We should finally have some working markers on our map. Let's check it out. Refresh your page and you should see this:

Map with markers

Awesome! We now have dynamic markers that will update each time you add or remove a place from your database, along with your maps zoom and center point. All that's left to do is add an InfoWindow component to our markers to display some information when we click on them.


5. Adding infowindows to markers


At the moment clicking on our markers yields no results which is terrible for user experience. Instead maybe we'd like to show the name of the place, the address, or any other information we make available.

We have to do a bit of extra work before we can implement these. First, we're going to make use of the useState hook due to the fact that we've defined Map as a functional component. First, let's import useState at the top of our component:

import React, { useState } from 'react'
Enter fullscreen mode Exit fullscreen mode

The reason we have to use this hook is because we need to be able to determine which marker we've clicked on in order to display the correct infowindow. To do this we'll create a state called selected. Inside our Map function, add the following at the top:

const Map = (props) => {
  const [selected, setSelected] = useState(null)
  ...
}
Enter fullscreen mode Exit fullscreen mode

We'll use this state together with an onClick function to tell React which marker we've clicked on. To do this, we need to add another property to the Marker components rendered from our .map:

{props.markers[0].map(marker => 
  <Marker
    key={marker.id}
    animation={2}
    position={{
      lat: marker.lat,
      lng: marker.lng,
    }}
    onClick={() => {
      setSelected(marker)
    }}
  />
)}
Enter fullscreen mode Exit fullscreen mode

Nothing's happening yet because we're not telling React what to do when the state changes. To do so, add this underneath your markers, before the closing tag of the GoogleMap component:

{selected ? 
  (<InfoWindow 
      position={{ lat: selected.lat, lng: selected.lng }}
      onCloseClick={() => {
        setSelected(null)
      }}
    >
    <div style={{ maxWidth: 120 }}>
      <p>{ selected.name }</p>
      <small>{ selected.address }</small>
    </div>
  </InfoWindow>) : null
}
Enter fullscreen mode Exit fullscreen mode

Here we're using a ternary operator to do a bit of conditional rendering for us. If the marker is selected (by clicking on it), it'll render an InfoWindow component that shows us the name and address of the chosen place. When we click the close button, it sets the selected state back to null in this line:

onCloseClick={() => {
  setSelected(null)
}}
Enter fullscreen mode Exit fullscreen mode

If the marker doesn't match the selected one, nothing is rendered. If you refresh your page, when you click on any marker you should see a small window appear:

Marker infowindow


6. Next steps


And there you have it, we now have a working map rendering markers and infowindows for each place we create. Where you go from here is entirely up to your imagination - this could be used to display branches of a company, cafes in a city, or tap into a location-based API to display events in an area.

If you plan to host your project online, make sure that you import your environment variables into whichever platform you choose as these will replace the data in the .env file.

Almost every aspect of the map can be customised, from the color of the map to the marker icons and infowindow contents like these from my final project at Le Wagon:

Safe Spaces infowindow

Feel free to clone the project and make it your own and adapt it. Share your thoughts and ideas in the comments!

Thank you for following along!


7. Get in touch

If you want to see more of my work, feel free to reach out through any of these platforms:

💖 💪 🙅 🚩
rhysmalyon
Rhys Malyon

Posted on November 12, 2021

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

Sign up to receive the latest update from our blog.

Related