You have successfully migrated your legacy google.maps.Marker code to the new, performance-optimized google.maps.marker.AdvancedMarkerElement. Your pins render beautifully, collisions are handled gracefully, and click events trigger as expected.
But then you attempt to implement a simple hover state—perhaps a tooltip or a z-index boost on mouseover—and nothing happens.
You try the standard marker.addListener("mouseover", cb). You try google.maps.event.addListener. You may even try attaching React synthetic events to a wrapper. They all fail silently or behave inconsistently.
This is a widespread issue for senior frontend engineers migrating to the Google Maps JavaScript API v3.53+. This post dissects why the event propagation model changed and provides a robust, copy-pasteable React solution to fix it.
The Root Cause: The "DOM Element" Paradigm Shift
To understand why your event listeners are failing, we must look at the architectural difference between Legacy Markers and Advanced Markers.
Legacy Markers (google.maps.Marker)
Legacy markers were essentially raster images or SVGs painted onto a canvas overlay or injected into a map pane. Because these weren't standard DOM elements in the traditional flow, Google provided the google.maps.event namespace to bridge the gap. When you added a listener, the Maps API intercepted the browser's mouse coordinates, calculated which marker was under the cursor, and synthetically fired the event.
Advanced Markers (AdvancedMarkerElement)
Advanced Markers are fundamentally different. They are actual HTML Elements (Web Components) rendered directly into the DOM.
The friction arises because Google decided to decouple the Marker Instance (the JavaScript object controlling position) from the Marker Content (the visual DOM node).
- API Event pruning: The
AdvancedMarkerElementinstance only officially supports specific interaction events, primarilygmp-click. It explicitly does not forward standard DOM events likemouseenter,mouseleave, ormousemove. - DOM Encapsulation: Since the marker is now just an HTML element, Google expects you to use native DOM event listeners on the
contentelement itself, rather than the abstract marker class.
If you attempt to use the legacy google.maps.event.addListener on an Advanced Marker instance for a hover event, it fails because the API no longer polyfills that interaction.
The Solution: Direct Content Injection with React
To fix this in React, we cannot rely on the Maps API event system. Instead, we must construct the marker's content as a DOM node, attach native event listeners to that node, and then pass that node to the AdvancedMarkerElement constructor.
Here is a production-ready, TypeScript-safe component that solves the hover issue while preventing memory leaks.
Prerequisites
Ensure you are loading the maps library using the modern dynamic import strategy.
// map-loader.ts
import { Loader } from "@googlemaps/js-api-loader";
export const loader = new Loader({
apiKey: process.env.NEXT_PUBLIC_GOOGLE_MAPS_KEY as string,
version: "weekly",
libraries: ["marker", "maps"], // "marker" is required for AdvancedMarkerElement
});
The Interactive Marker Component
This component creates a div, attaches listeners, and synchronizes with the Google Maps instance.
import React, { useEffect, useRef, useState } from 'react';
interface InteractiveMarkerProps {
map: google.maps.Map | null;
position: google.maps.LatLngLiteral;
children: React.ReactNode; // The visual content of the pin
onHoverStart?: () => void;
onHoverEnd?: () => void;
onClick?: () => void;
zIndex?: number;
}
export const InteractiveMarker: React.FC<InteractiveMarkerProps> = ({
map,
position,
children,
onHoverStart,
onHoverEnd,
onClick,
zIndex = 1,
}) => {
const markerRef = useRef<google.maps.marker.AdvancedMarkerElement | null>(null);
const rootElementRef = useRef<HTMLDivElement | null>(null);
// 1. Initialize the DOM Container
// We create a container div that will act as the `content` property
// of the AdvancedMarkerElement.
if (!rootElementRef.current) {
rootElementRef.current = document.createElement("div");
rootElementRef.current.style.cursor = "pointer";
// Critical: Ensure pointer-events are active, otherwise the map
// canvas captures the mouse before the element does.
rootElementRef.current.style.pointerEvents = "auto";
}
useEffect(() => {
if (!map || !rootElementRef.current) return;
const initMarker = async () => {
// 2. Import the library strictly within the effect
const { AdvancedMarkerElement } = await google.maps.importLibrary("marker") as google.maps.MarkerLibrary;
// 3. Create the Marker Instance
// Notice we pass `rootElementRef.current` as the content.
markerRef.current = new AdvancedMarkerElement({
map,
position,
content: rootElementRef.current,
zIndex,
// Accessibility title improves standard tooltip behavior
title: "Map Marker",
});
// 4. Attach Native DOM Listeners
// We attach to the DOM node, NOT the marker instance.
const node = rootElementRef.current;
if (!node) return;
const handleEnter = () => onHoverStart?.();
const handleLeave = () => onHoverEnd?.();
const handleClick = () => onClick?.();
node.addEventListener("mouseenter", handleEnter);
node.addEventListener("mouseleave", handleLeave);
node.addEventListener("click", handleClick);
// 5. Cleanup Function
// Essential for SPA navigation to prevent memory leaks and zombie listeners.
return () => {
node.removeEventListener("mouseenter", handleEnter);
node.removeEventListener("mouseleave", handleLeave);
node.removeEventListener("click", handleClick);
// Remove marker from map
if (markerRef.current) {
markerRef.current.map = null;
markerRef.current = null;
}
};
};
const cleanupPromise = initMarker();
return () => {
cleanupPromise.then(cleanup => cleanup && cleanup());
};
}, [map, position, zIndex]); // Re-run if position changes
// 6. React Portal for Content Rendering
// We use a Portal to render the React children *into* the
// raw DOM node we created. This allows us to use standard React
// components (CSS-in-JS, Tailwind, etc.) inside the marker.
if (!rootElementRef.current) return null;
// Use createPortal from 'react-dom' (React 18 or 'react-dom/client')
const { createPortal } = require('react-dom');
return createPortal(children, rootElementRef.current);
};
Deep Dive: Why This Implementation Works
1. The Portal Pattern
The snippet uses React.createPortal. This is non-negotiable. AdvancedMarkerElement requires a raw DOM node (HTMLElement) for its content. However, we want to write React JSX for the visual design of the pin. The Portal bridges this gap by allowing React to render a component tree into a DOM node (rootElementRef.current) that exists outside the standard React hierarchy, which we then hand over to Google Maps.
2. Native addEventListener vs. Map Listeners
By attaching mouseenter directly to the div inside useEffect, we bypass the Google Maps event abstraction layer entirely. We are interacting with the browser's DOM API directly. This provides immediate feedback and supports event bubbling controls (like stopPropagation) if you have interactive buttons inside your marker.
3. Lifecycle Management
The return statement in the useEffect is critical. Google Maps markers persist on the map canvas even if the React component unmounts. Without setting marker.map = null, your markers will remain "ghosted" on the map, and your event listeners will remain in memory, causing significant performance degradation over time.
Common Pitfalls and Edge Cases
The pointer-events Gotcha
If your CSS styling for the marker content (or a parent wrapper) includes pointer-events: none, the hover event will fall through the marker and hit the map canvas instead. Fix: Explicitly set pointer-events: auto on the container div you pass to marker.content.
Z-Index Wars
When a user hovers over a marker, you usually want it to render above surrounding markers. With AdvancedMarkerElement, changing the zIndex prop triggers a re-paint. In the component above, the dependency array includes zIndex. To implement a "bring to front" effect, manage the zIndex in the parent component state:
// Usage Example
const [hoveredId, setHoveredId] = useState<string | null>(null);
// ... inside your map loop
<InteractiveMarker
position={loc.coords}
map={mapInstance}
zIndex={hoveredId === loc.id ? 999 : 1} // Dynamic Z-Index
onHoverStart={() => setHoveredId(loc.id)}
onHoverEnd={() => setHoveredId(null)}
>
<MyCustomPin isHovered={hoveredId === loc.id} />
</InteractiveMarker>
Collision Behavior
Google Maps Advanced Markers support collision behavior (hiding markers that overlap). If you use AdvancedMarkerElement.collisionBehavior = 'OPTIONAL_AND_HIDES_LOWER_PRIORITY', be aware that hidden markers obviously cannot trigger mouse events. However, if the collision engine hides a marker while your mouse is over it, the mouseleave event may not fire immediately depending on browser implementation. Always guard your state updates.
Conclusion
The shift to AdvancedMarkerElement is a necessary step for modern map performance, but it shifts the responsibility of event handling from the library to the developer.
By treating the marker content as a standard DOM node and leveraging React Portals, we regain full control over hover states, focus management, and accessibility features, resulting in a significantly more polished user experience.