How do you Integrate Google Maps API with Phoenix LiveView

Lionel Aimerie
Elemental Elixir
Published in
8 min readJan 30, 2024

This is quite simply one of the most creative ways to give your web applications a boost by adding dynamic and interactive maps. Now, if the truth be told, I’ve always been impressed by the elegance and immediate reactivity of Phoenix Live View. As an avid user of the Phoenix framework myself, I’ve tinkered around enough with this feature and have found two very practical ways of adding markers to Google Maps.

If you don’t already know, Phoenix LiveView enables the development of rich, interactive user interfaces with server-rendered HTML. And unlike conventional single-page application (SPA) frameworks that are heavily reliant on continual client-server interaction through AJAX or WebSocket connections, Phoenix LiveView takes a different path. It operates on the principle of “server-rendered HTML with real-time updates”, significantly reducing the necessity for frequent communication. This not only simplifies development but also enhances performance and ultimately, user experience.

A Phoenix flying above a Globe

Setup

Setting up the Google Maps API involves a few key steps. We’ll go through the process of creating a new Google Cloud project, generating an API key, and enabling the Maps JavaScript API. Once we’ve covered these essential tasks, you’ll be on your way to enhancing your web application with dynamic and interactive maps.

  1. Create a new Google Cloud project.
    Go to Google Cloud Console and create a new project. Provide a name and submit it.
  2. Generate an API Key.
    Navigate to the project console: Google Cloud Console Go to API & Services > Credentials > Create Credentials > API Key. Save your API key somewhere you’ll be able to find it.
  3. Enable Maps JavaScript API.
    Enable the Maps JavaScript API. Restrict the API key by referrer (Project > Keys and Credentials > Edit API Key).
    Use the provided referrer URLs:
http://localhost:4000/*
https://app.mysite.com/*
(e.g. https://*.movo-soft.com/)

Let’s add it to Phoenix

Now that we have the Google Maps API configured and enabled, let’s integrate it into our Phoenix LiveView project. In this section, we’ll create a new Phoenix project, set up PostgreSQL using Docker, and ensure our development environment is ready for the upcoming dynamic map enhancements.

Create a new Phoenix project.

You know the drill:

mix phx.new gmap
# ... install the dependencies
cd gmap

Setup PostgreSQL using Docker

Use the provided docker-compose.yml file to set up PostgreSQL using Docker.

version: "3.8"

services:
database:
# === KEEP IT SIMPLE
container_name: db-postgres
image: postgres:16.0-alpine
# the next line only if you have an ARM64 architecture (i.e. M1-3 Macs)
platform: linux/arm64
ports:
- 5432:5432
environment:
- POSTGRES_DB=postgres
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
# default: /var/lib/postgresql/data
# better in a subfolder for volume mounts
# see <https://hub.docker.com/_/postgres>
- PGDATA=/var/lib/postgresql/data/pgdata
volumes:
# if you're using git don't forget to gitignore
- ./.postgres:/var/lib/postgresql/data

Start the service and create the database.

docker compose up -d
# [+] Running 2/2
# ✔ Network gmap_default Created 0.0s
# ✔ Container db-postgres Started

mix setup
mix phx.server
# ... Done in XXXms

Add Google Maps Library

cd assets
npm install @googlemaps/js-api-loader --save
# added 2 packages...

Add a hook for Google Maps

Open lib/gmap_web/controllers/page_html/home.html.heex and add the following div:

<div
id="googleMap"
class="w-full bg-slate-100"
phx-hook="GMaps"
phx-update="ignore"
></div>

By setting phx-update="ignore", you are instructing Phoenix LiveView to ignore updates to this specific element when changes occur in the assigns to avoid re-drawing the entire element.

Add the hook to assets/js/app.js.

import GMaps from "./hookGMaps"

const Hooks = { GMaps: GMaps };
...
let liveSocket = new LiveSocket("/live", Socket, {params: {
_csrf_token: csrfToken,
// in minutes
utc_offset: (new Date()).getTimezoneOffset()
}, hooks: Hooks
})

Add Google Maps hook code:

import { Loader } from "@googlemaps/js-api-loader"
export default {
mounted() {
const hook = this;
// the element hooked
elt = hook.el;
hook.map = null;
const loader = new Loader({
// The secret could be passed through data-attribute
apiKey: '<YOUR_API_KEY>',
version: "weekly"
});
loader
.importLibrary('maps')
.then(({Map}) => {
hook.map = new Map(elt, {
center: { lat: 1.29847, lng: 103.844421 },
zoom: 11,
// additional map configuration attributes
zoomControl: true,
mapTypeControl: false,
scaleControl: false,
streetViewControl: false,
rotateControl: false,
fullscreenControl: true,
styles: [
{
"featureType": "all",
"stylers": [
{ "saturation": -100 }
]
},
{
"featureType": "water",
"stylers": [
{ "saturation": 0 ,
// "color": "#30A4DC"
"color": "#79b0cb"
}
]
}
]
});
});
}
}

The Google Maps documentation has a tendency to be a tad heavy. As such, I’ve added a lot of configuration attributes so that you get an idea of what goes where.

If all went to plan, a map should appear centered on one of my usual working haunts.

Let’s add some markers!

With the foundation set, it’s time to make our map more interesting by adding markers to pinpoint specific targets. In this section, we’ll explore creating a data model for our targets and dynamically place markers on the map.

First, let’s create the data model for a target:

mix phx.gen.live Targets Target targets name:string lat:float lng:float

Integrate the new routes into router.ex, execute the migration, and run the tests to ensure everything is functioning smoothly. Assuming all is well, let's proceed to updating lib/gmap_web/live/target_live/index.html.heex with the following source code:

<.header>
Maps!
<:actions>
<.button phx-click="update_targets">Update</.button>
</:actions>
</.header>

<div id="googleMap" class="w-full h-96 bg-slate-100 border-2"
phx-hook="GMaps"
phx-update="ignore">
</div>

Additionally, I’ve included an action button labeled “Update” for testing the ability to dynamically modify markers later on.

Seed the targets

We now need to get some example targets, which can be seeded using priv/repo/seeds.exs:

[
{"Target 1", 1.2953493105364986, 103.85826194711629},
{"Target 2", 1.2946628404229832, 103.86017167980194},
{"Target 3", 1.2947915535834391, 103.85798299739815},
{"Target 4", 1.2935473261018144, 103.8506659312996}
]
|> Enum.map(fn {name, lat, lon} ->
now = DateTime.utc_now(:second)

%{
name: name,
inserted_at: now,
updated_at: now,
lat: lat,
lng: lon
}
end)
|> then(&Gmap.Repo.insert_all(Gmap.Targets.Target, &1, on_conflict: :nothing))

After running the script, your database should now contain four targets.

mix run priv/repo/seeds.exs

Markers

Now that we have targets defined by the longitudinal and latitudinal values, we can focus on loading and displaying markers on our Google Map. Let’s explore two different methods — lifecycle events and pushed events.

Lifecycle Events

In Phoenix LiveView, lifecycle events for hooks represent various stages in the life of a LiveView component’s associated DOM element. The lifecycle events for hooks are similar to those found in many JavaScript frameworks, allowing developers to execute custom logic at different points in the component’s lifecycle.

The idea here is to pass the data for the targets to the hook using updates of the hooked DOM element. To do so, we can leverage the data-xxx attributes on the hooked DOM element and fetch the data from hookGMaps.js.

Let’s proceed to modify lib/gmap_web/live/target_live/index.html.heex to add targets as a data attribute.

...
<div
id="googleMap"
class="w-full h-96 bg-slate-100 border-2"
phx-hook="GMaps"
phx-update="ignore"
data-targets={Jason.encode!(@targets)}
></div>

Next, we make the targets available as an assign within the LiveView

def mount(_params, _session, socket) do
{:ok, socket
|> assign(:targets, Targets.list_targets())}
end

We also need to ensure the targets are easily JSON-encodable. Fortunately, achieving this is a walk in the park using Elixir.

In lib/gmap/targets/target.ex simply add:

... import Ecto.Changeset

@derive {Jason.Encoder, only: [:id, :name, :lat, :lng]}

schema "targets" do ...

In Elixir, the @derive attribute is used to specify that a data structure should automatically implement certain protocols or behaviors. In this case, @derive {Jason.Encoder, only: [:id, :name, :lat, :lng]} is used with the Jason.Encoder protocol.

Let’s modify hookGMaps.js to create Google Maps markers:

export default {
mounted() {
const hook = this;
...
loader
.importLibrary('maps')
.then(({Map}) => {
...
// let's add a function to update markers from input
hook.updateMarkers = function() {
const targets = JSON.parse(hook.el.dataset.targets)
// const targets = hook.targets;
console.log("updating markers", targets);
targets.forEach(({id, name, lat, lng}) => {
const marker = new google.maps.Marker({
// Map.addMarker({
position: { lat: lat, lng: lng },
map: hook.map,
title: name,
label: {
text: "X", fontSize: "0.8rem", color: 'white'
}
});
});
}

// let's update the map with data provided in dom object
hook.updateMarkers();
}
}
}

And Voila! We’ve successfully added markers.

Now, let’s make a slight adjustment to the code to load the markers upon clicking the “Update” button.

In lib/gmap_web/live/target_live/index.ex, let’s change the mount() function and add an event handler to assign the targets:

def mount(_params, _session, socket) do
{:ok, socket |> assign(:targets, [])}
end

@impl true
def handle_event("update_targets", _params, socket) do
{:noreply,
socket
|> assign(:targets, Targets.list_targets())}
end

We can leverage lifecycle events in the hook to update the markers precisely during the updated() event.

export default {
mounted() { ... },
updated() {
const hook = this;
console.log("updated");
hook.updateMarkers();
}
}

Another Voila! moment, ladies and gents!

Pushed Events

Let’s now explore a second method to transmit the targets from LiveView to hook.

In the context of Phoenix LiveView, push_events refer to a mechanism by which the server can send events to the client over the established WebSocket connection. These events are then handled on the client side, allowing for real-time updates without the need for explicit client requests.

Proceed to update the LiveView once more and remove the data-targets attribute.

<div
id="googleMap"
class="w-full h-96 bg-slate-100 border-2"
phx-hook="GMaps"
phx-update="ignore"
></div>

In the index.ex file, modify the event handler to push an event “update_markers” with the list of targets as a parameter.

def handle_event("update_targets", _params, socket) do
# V2 -- push event style
{:noreply,
socket
|> push_event(
"update_markers",
%{data: Targets.list_targets()}
)}
end

In the hookGMaps.js file, let’s incorporate the event handler within the mounted() function and update the updateMarkers() function to handle the payload:

hook.handleEvent("update_markers", (payload) => {
console.log("event-markers");
hook.updateMarkers(payload.data);
});

hook.updateMarkers = function(data = []) {
const targets = payload;
...
}

We’re almost there guys!

Now, what if you want to initialize the view with some markers already? Would adding the push event initially suffice?

Let’s attempt this it in index.ex

defp apply_action(socket, :index, _params) do
socket
|> push_event(
"update_markers",
%{data: Targets.list_targets()}
)
end

Now, just to add a dose of drama, you’ll notice that something’s wrong. Don’t panic, just take a moment to inspect the console.

So what happened? Well, the function wasn’t accessible at the time the event was pushed because the Google Maps module had not been loaded yet.

Fortunately, this can be resolved by separating data assignment and marker rendering.

So, let’s modify hookGMaps.js

mounted() {
const hook = this;

hook.map = null;
// let's declare a variable to save our temp data
hook.targets = [];
hook.handleEvent("update_markers", (payload) => {
console.log("event-markers", payload);
hook.targets = payload.data;
// only update if the map is ready
if (hook.map) { hook.updateMarkers(); }
});

hook.updateMarkers = function()
const targets = hook.targets;
...
}
}

And veni, vidi, vici.

--

--