Page Summary
-
This example demonstrates building a location services web app using Google Maps Platform's Extended Component Library and the vis.gl/react-google-maps library.
-
The Extended Component Library provides pre-built UI elements simplifying complex map interactions, while vis.gl/react-google-maps offers React components for Google Maps integration.
-
Users can search for colleges in the US or Canada, view place details, reviews, and get directions.
-
The sample code includes TypeScript and JavaScript versions, along with CSS and HTML for styling and structure.
-
The application can be run locally by cloning the repository, installing dependencies, and starting the application with provided commands.
This example shows how to build a basic locations services web app using the Google Maps Platform's Extended Component Library with the vis.gl/react-google-maps open source library.
Google Maps Platform's Extended Component Library is a set of Web Components that helps developers build better maps faster, and with less effort. It encapsulates boilerplate code, best practices, and responsive design, reducing complex map UIs into what is effectively a single HTML element. These components make it easier to read, learn, customize, and maintain maps-related code.
The vis.gl/react-google-maps library is a collection of React components and hooks for the Google Maps JavaScript API.
TypeScript
import React, { useState, useRef, useEffect } from 'react'; import { createRoot } from 'react-dom/client'; import { AdvancedMarker, Map, Pin, APIProvider, } from '@vis.gl/react-google-maps'; import { PlaceReviews, PlaceDataProvider, PlaceDirectionsButton, IconButton, PlaceOverview, SplitLayout, OverlayLayout, PlacePicker, } from '@googlemaps/extended-component-library/react'; /** * The below imports are necessary because we are creating refs of * the OverlayLayout and PlacePicker components. You need to pass * the ref property a web component type object. Imports from * @googlemaps/extended-component-library/react are wrappers around * the web components, not the components themselves. For the ref * property we import the actual components and alias them for clarity. */ import { OverlayLayout as TOverlayLayout } from '@googlemaps/extended-component-library/overlay_layout.js'; import { PlacePicker as TPlacePicker } from '@googlemaps/extended-component-library/place_picker.js'; const COUNTRIES = ['us', 'ca']; const API_KEY = 'GOOGLE_MAPS_API_KEY'; const DEFAULT_CENTER = { lat: 38, lng: -98 }; const DEFAULT_ZOOM = 4; const DEFAULT_ZOOM_WITH_LOCATION = 16; /** * Sample app that helps users locate a college on the map, with place info such * as ratings, photos, and reviews displayed on the side. */ export default function App() { const overlayLayoutRef = useRef<TOverlayLayout>(null); const pickerRef = useRef<TPlacePicker>(null); const [college, setCollege] = useState< google.maps.places.Place | undefined >(undefined); /** * We track the map's camera state separately and use an onCameraChanged listener. * This prevents the map from becoming strictly "controlled" by college.location, * which would otherwise lock the camera and prevent the user from panning or zooming. */ const [cameraProps, setCameraProps] = useState({ center: DEFAULT_CENTER, zoom: DEFAULT_ZOOM, }); useEffect(() => { if (college?.location) { setCameraProps({ center: { lat: college.location.lat(), lng: college.location.lng(), }, zoom: DEFAULT_ZOOM_WITH_LOCATION, }); } }, [college]); /** * See https://lit.dev/docs/frameworks/react/#using-slots for why * we need to wrap our custom elements in a div with a slot attribute. */ return ( <div className="App"> <APIProvider solutionChannel="GMP_devsite_samples_v3_rgmcollegepicker" apiKey={API_KEY} version="beta"> <SplitLayout rowReverse rowLayoutMinWidth={700}> <div className="SlotDiv" slot="fixed"> <OverlayLayout ref={overlayLayoutRef}> <div className="SlotDiv" slot="main"> <PlacePicker className="CollegePicker" ref={pickerRef} forMap="gmap" country={COUNTRIES} type="university" placeholder="Enter a college in the US or Canada" onPlaceChange={() => { if (!pickerRef.current?.value) { setCollege(undefined); } else { setCollege(pickerRef.current.value); } }} /> <PlaceOverview size="large" place={college} googleLogoAlreadyDisplayed> <div slot="action" className="SlotDiv"> <IconButton slot="action" variant="filled" onClick={() => { if (overlayLayoutRef.current) void overlayLayoutRef.current.showOverlay(); }}> See Reviews </IconButton> </div> <div slot="action" className="SlotDiv"> <PlaceDirectionsButton slot="action" variant="filled"> Directions </PlaceDirectionsButton> </div> </PlaceOverview> </div> <div slot="overlay" className="SlotDiv"> <IconButton className="CloseButton" onClick={() => { if (overlayLayoutRef.current) void overlayLayoutRef.current.hideOverlay(); }}> Close </IconButton> <PlaceDataProvider place={college}> <PlaceReviews /> </PlaceDataProvider> </div> </OverlayLayout> </div> <div className="SplitLayoutContainer" slot="main"> <Map id="gmap" mapId="8c732c82e4ec29d9" {...cameraProps} onCameraChanged={(ev) => { setCameraProps(ev.detail); }}> {college?.location && ( <AdvancedMarker position={college.location}> <Pin background={'#FBBC04'} glyphColor={'#000'} borderColor={'#000'} /> </AdvancedMarker> )} </Map> </div> </SplitLayout> </APIProvider> </div> ); } export function renderToDom(container: HTMLElement) { const root = createRoot(container); root.render( <React.StrictMode> <App /> </React.StrictMode> ); }
JavaScript
import React, { useState, useRef, useEffect } from 'react'; import { createRoot } from 'react-dom/client'; import { AdvancedMarker, Map, Pin, APIProvider, } from '@vis.gl/react-google-maps'; import { PlaceReviews, PlaceDataProvider, PlaceDirectionsButton, IconButton, PlaceOverview, SplitLayout, OverlayLayout, PlacePicker, } from '@googlemaps/extended-component-library/react'; const COUNTRIES = ['us', 'ca']; const API_KEY = 'GOOGLE_MAPS_API_KEY'; const DEFAULT_CENTER = { lat: 38, lng: -98 }; const DEFAULT_ZOOM = 4; const DEFAULT_ZOOM_WITH_LOCATION = 16; /** * Sample app that helps users locate a college on the map, with place info such * as ratings, photos, and reviews displayed on the side. */ export default function App() { const overlayLayoutRef = useRef(null); const pickerRef = useRef(null); const [college, setCollege] = useState(undefined); /** * We track the map's camera state separately and use an onCameraChanged listener. * This prevents the map from becoming strictly "controlled" by college.location, * which would otherwise lock the camera and prevent the user from panning or zooming. */ const [cameraProps, setCameraProps] = useState({ center: DEFAULT_CENTER, zoom: DEFAULT_ZOOM, }); useEffect(() => { if (college?.location) { setCameraProps({ center: { lat: college.location.lat(), lng: college.location.lng(), }, zoom: DEFAULT_ZOOM_WITH_LOCATION, }); } }, [college]); /** * See https://lit.dev/docs/frameworks/react/#using-slots for why * we need to wrap our custom elements in a div with a slot attribute. */ return (React.createElement("div", { className: "App" }, React.createElement(APIProvider, { solutionChannel: "GMP_devsite_samples_v3_rgmcollegepicker", apiKey: API_KEY, version: "beta" }, React.createElement(SplitLayout, { rowReverse: true, rowLayoutMinWidth: 700 }, React.createElement("div", { className: "SlotDiv", slot: "fixed" }, React.createElement(OverlayLayout, { ref: overlayLayoutRef }, React.createElement("div", { className: "SlotDiv", slot: "main" }, React.createElement(PlacePicker, { className: "CollegePicker", ref: pickerRef, forMap: "gmap", country: COUNTRIES, type: "university", placeholder: "Enter a college in the US or Canada", onPlaceChange: () => { if (!pickerRef.current?.value) { setCollege(undefined); } else { setCollege(pickerRef.current.value); } } }), React.createElement(PlaceOverview, { size: "large", place: college, googleLogoAlreadyDisplayed: true }, React.createElement("div", { slot: "action", className: "SlotDiv" }, React.createElement(IconButton, { slot: "action", variant: "filled", onClick: () => { if (overlayLayoutRef.current) void overlayLayoutRef.current.showOverlay(); } }, "See Reviews")), React.createElement("div", { slot: "action", className: "SlotDiv" }, React.createElement(PlaceDirectionsButton, { slot: "action", variant: "filled" }, "Directions")))), React.createElement("div", { slot: "overlay", className: "SlotDiv" }, React.createElement(IconButton, { className: "CloseButton", onClick: () => { if (overlayLayoutRef.current) void overlayLayoutRef.current.hideOverlay(); } }, "Close"), React.createElement(PlaceDataProvider, { place: college }, React.createElement(PlaceReviews, null))))), React.createElement("div", { className: "SplitLayoutContainer", slot: "main" }, React.createElement(Map, { id: "gmap", mapId: "8c732c82e4ec29d9", ...cameraProps, onCameraChanged: (ev) => { setCameraProps(ev.detail); } }, college?.location && (React.createElement(AdvancedMarker, { position: college.location }, React.createElement(Pin, { background: '#FBBC04', glyphColor: '#000', borderColor: '#000' }))))))))); } export function renderToDom(container) { const root = createRoot(container); root.render(React.createElement(React.StrictMode, null, React.createElement(App, null))); }
CSS
body { margin: 0; font-family: sans-serif; } #app { width: 100vw; height: 100vh; } :root { --gmpx-fixed-panel-width-row-layout: 450px; } .App { --gmpx-color-surface: #f6f5ff; --gmpx-color-on-primary: #f8e8ff; --gmpx-color-on-surface: #000; --gmpx-color-on-surface-variant: #636268; --gmpx-color-primary: #8a5cf4; --gmpx-fixed-panel-height-column-layout: 420px; background: var(--gmpx-color-surface); inset: 0; position: fixed; } .MainContainer { display: flex; flex-direction: column; } .SplitLayoutContainer { height: 100%; } /* * Because the parent uses display: contents, flex-grow won't work. * We explicitly set the width here to stretch the search box, which * also ensures the Maps Autocomplete dropdown matches this width. */ .CollegePicker { --gmpx-color-surface: #fff; margin: 1rem; width: calc(100% - 2rem); box-sizing: border-box; } .CloseButton { display: block; margin: 1rem; } .SlotDiv { display: contents; }
HTML
<html lang="en">
<head>
<meta charset="utf-8" />
<!-- Prevents reviewer profile photos from breaking due to cross-origin Referer restrictions -->
<meta name="referrer" content="no-referrer" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, user-scalable=no" />
<title>React - College Picker</title>
<meta name="description" content="React College Picker Example" />
<link rel="stylesheet" href="./style.css" />
<script type="module">
import { renderToDom } from './src/app';
renderToDom(document.querySelector('#app'));
</script>
</head>
<body>
<div id="app"></div>
</body>
</html>Clone Sample
Git and Node.js are required to run this sample locally. Follow these instructions to install Node.js and NPM. The following commands clone, install dependencies and start the sample application.
git clone https://github.com/googlemaps-samples/js-api-samples.gitcd samples/rgm-college-pickernpm inpm start
Integration notes
When integrating Google Maps Web Components (Extended Component Library) and
vis.gl/react-google-maps within a React application, you may encounter
friction between React's declarative state model and built-in browser APIs. This
section describes the specific workarounds implemented in this sample to ensure
expected behavior.
Use slots for web component composition
React (prior to v19) struggles to pass the standard HTML slot
attribute directly to nested Web Components.
To work around this, wrap the slotted elements inside a standard HTML <div>
(for example, <div className="SlotDiv" slot="main">).
If the parent component uses Flexbox, adding an intermediate <div> can
disrupt the layout hierarchy. To avoid this, apply display: contents; to the
wrapper <div>. This removes the wrapper's box from the layout tree, allowing
child components to participate directly in the parent's Flexbox layout.
Caveat with Web Component sizing:
Because display: contents causes the wrapper <div> to generate no box, any
styling (such as width, margin, or flex-grow) applied directly to the
wrapper <div> is ignored. Furthermore, custom elements default to
display: inline-block and won't automatically stretch to fill available
horizontal space.
To ensure proper layout, apply styles directly to the child component host:
.CollegePicker {
width: calc(100% - 2rem);
box-sizing: border-box;
}
Because the <PlacePicker> autocomplete drop-down matches the physical width
of its host input, properly sizing the host component resolves drop-down
alignment without requiring global CSS overrides on .pac-container.
Retain Map Pan & Zoom (Controlled Camera State)
Passing the location directly to the map (for example,
<Map center={college.location} />) creates a strictly "Controlled Component"
in @vis.gl/react-google-maps. Anytime the user tries to pan the map, React
immediately snaps the camera back to the locked college.location coordinate,
effectively breaking panning and zooming.
To solve this, decouple the map's camera from the selected place. In this example, we did the following:
- Created a dedicated
cameraPropsReact state to track the map's current center and zoom. - Added an
onCameraChangedevent listener to the map so it could smoothly update its own state while the user dragged it. - Created a
useEffecthook that explicitly intercepts a new college selection and programmatically updates thecameraProps. This allows the map to fly to new searches while remaining fully interactive.
Use stable references to prevent render loops
Passing inline array or object literals directly to component props (for
example, <PlacePicker country={['us', 'ca']} />) creates a new object
reference in memory on every render. Because React wrappers for Web Components
and hooks often synchronize properties or trigger effects based on reference
equality (===), an unstable reference makes the component treat the property
as constantly changing. This can trigger continuous updates, redundant network
requests, or infinite re-render loops.
To avoid this, ensure that non-primitive props maintain stable references across renders:
- Define static arrays or configuration objects outside the component scope (for
example,
const COUNTRIES = ['us', 'ca'];). - For dynamic objects or arrays that depend on props or state, memoize them using
the
useMemohook.
In this example, we extracted the allowed countries into a top-level COUNTRIES
constant and passed country={COUNTRIES}, providing a stable reference that
prevents repeated updates.
Ensure that images load in the Shadow DOM
Google's image servers (lh3.googleusercontent.com) block requests that
include third-party Referer headers to prevent unauthorized
hotlinking. Because browsers send the origin in the Referer header by
default, loading reviewer avatars from any third-party domain (both in
production and on localhost) returns an HTTP 403 Forbidden error, which
causes images to fail to load.
Because the underlying <img> tags reside inside the Web Component's Shadow
DOM, you cannot directly add referrerpolicy="no-referrer" to the individual
image elements.
To fix this, apply a document-level referrer policy in the <head> of your
HTML page:
<meta name="referrer" content="no-referrer" />
This instructs the browser to omit the Referer header for outgoing requests,
allowing images to load correctly.
Fix Implicit Void Returns in React Handlers
Using arrow function shorthands for event handlers that return Promises or
void (e.g., onClick={() => overlayLayoutRef.current.showOverlay()})
causes type errors in TypeScript.
To fix this, wrap event handlers in curly braces { ... } and use the void
operator for fire-and-forget Promises. This prevents accidental return values
from being passed to React's event system.