You drop 5,000 markers onto a Google Map in React. The browser fan kicks in. Pan lag. CPU pegged. Users complain.

I’ve been there. The fix is clustering: aggregate nearby markers into a single visual until the user zooms in. Google’s Marker Clustering API does this natively, but it only renders raw image markers. I needed actual React components as my markers and clusters. Material-UI popovers, animations, Redux-aware state, custom interactions. That meant rolling my own stack.

This is the pattern I landed on after a few rebuilds. Plus five gotchas that ate weeks of my time. Writing it down so you don’t have to repeat them.

Heads up before you read. I shipped this around 2020, so the stack is roughly 5 years old at this point. The pattern still holds up well, but the React map ecosystem has moved on. The three-component split, the gotchas, and the fixes are all still relevant. The specific libraries are not what I’d reach for today. I’ve added a “What I’d use in 2026” section at the end so you can see both sides.

The stack

The library does have clustering examples, but they’re written in older React idioms (class components, lifecycle methods). Here’s a modern hook-based version that mirrors how I shipped it.

The three-component split

At the core, clustering breaks into three jobs:

  1. GoogleMap owns the map instance, points data, current bounds and zoom, and runs the clustering algorithm.
  2. Cluster renders an aggregated marker when several points sit close together.
  3. Marker renders a single point when zoomed in or isolated.

1. GoogleMap.js

The map owns everything. Bounds, zoom, the points list, and the clustering call. Here’s what mine looks like:

import React, { useState, useMemo } from "react";
import GoogleMapReact from "google-map-react";
import supercluster from "points-cluster";
import Marker from "./Marker";
import Cluster from "./Cluster";

const GoogleMap = ({ points, apiKey, defaultCenter, defaultZoom }) => {
  const [mapProps, setMapProps] = useState({
    center: defaultCenter,
    zoom: defaultZoom,
    bounds: null,
  });

  const clusters = useMemo(() => {
    if (!mapProps.bounds) return [];
    return supercluster(points, {
      minZoom: 0,
      maxZoom: 16,
      radius: 60,
    })({ bounds: mapProps.bounds, zoom: mapProps.zoom });
  }, [points, mapProps.bounds, mapProps.zoom]);

  return (
    <div style={{ height: "100vh", width: "100%" }}>
      <GoogleMapReact
        bootstrapURLKeys={{ key: apiKey }}
        defaultCenter={defaultCenter}
        defaultZoom={defaultZoom}
        yesIWantToUseGoogleMapApiInternals
        onChange={({ center, zoom, bounds }) =>
          setMapProps({ center, zoom, bounds })
        }
        options={{
          restriction: {
            latLngBounds: {
              north: 85, south: -85, west: -180, east: 180,
            },
            strictBounds: true,
          },
        }}
      >
        {clusters.map((c) =>
          c.numPoints === 1 ? (
            <Marker
              key={c.points[0].id}
              lat={c.y}
              lng={c.x}
              point={c.points[0]}
            />
          ) : (
            <Cluster
              key={`${c.x}-${c.y}-${c.numPoints}`}
              lat={c.y}
              lng={c.x}
              count={c.numPoints}
            />
          )
        )}
      </GoogleMapReact>
    </div>
  );
};

export default GoogleMap;

2. Cluster.js

A circle with a number on it, sized by density. That’s it.

import React from "react";
import styled from "styled-components";

const ClusterBubble = styled.div`
  width: ${(p) => p.size}px;
  height: ${(p) => p.size}px;
  border-radius: 50%;
  background: ${(p) => p.color};
  color: white;
  display: flex;
  align-items: center;
  justify-content: center;
  font-weight: 600;
  cursor: pointer;
  transform: translate(-50%, -50%);
`;

const getStyle = (count) => {
  if (count < 10)   return { size: 36, color: "#42a5f5" };
  if (count < 100)  return { size: 48, color: "#26a69a" };
  if (count < 1000) return { size: 60, color: "#ff7043" };
  return { size: 72, color: "#d32f2f" };
};

const Cluster = ({ count }) => {
  const { size, color } = getStyle(count);
  return <ClusterBubble size={size} color={color}>{count}</ClusterBubble>;
};

export default Cluster;

3. Marker.js

A pin that opens an info window on click. The selected state comes from Redux so the rest of the app can react to it.

import React from "react";
import { useDispatch, useSelector } from "react-redux";
import { Popper } from "@mui/material";
import styled from "styled-components";
import { selectMarker } from "./store/mapSlice";

const Pin = styled.div`
  width: 24px;
  height: 24px;
  border-radius: 50%;
  background: #1976d2;
  border: 3px solid #fff;
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
  cursor: pointer;
  transform: translate(-50%, -50%);
`;

const Marker = ({ point }) => {
  const dispatch = useDispatch();
  const selectedId = useSelector((s) => s.map.selectedId);
  const anchorRef = React.useRef(null);
  const open = selectedId === point.id;

  return (
    <>
      <Pin ref={anchorRef} onClick={() => dispatch(selectMarker(point.id))} />
      <Popper open={open} anchorEl={anchorRef.current} placement="top">
        <InfoWindow point={point} />
      </Popper>
    </>
  );
};

export default Marker;

That’s the easy part. Now the parts that actually cost me time.

Five gotchas that ate my weeks

1. Keeping the selected marker in sync across three views

Three things need to know which marker is currently selected: the marker itself (to show its info window), the side list view (to highlight the row), and a “view on map” button (to fly to it).

I started by lifting state to the map component. That worked for about a week. Then I added the list. Then I wanted the URL to reflect the selection. By the third feature my map component was a state-management dumpster fire.

Moved it into Redux. Every component subscribes to state.map.selectedId and dispatches selectMarker(id). Boring solution. Boring is what you want in production.

2. Cluster bubbles that don’t scale with density

A cluster wrapping 5 markers shouldn’t look the same as one wrapping 500. Obvious in hindsight, but easy to forget when you’re sprinting through layout.

I broke getStyle() into four buckets (10, 100, 1000, and 1000+). Different sizes, different colors. Ten minutes of work, biggest visible improvement of the whole project.

3. Info window broke in fullscreen mode

This one cost me a Friday afternoon and most of a Saturday morning.

Material-UI’s Popper worked fine. Until someone clicked Google Maps’ fullscreen button. The popper either rendered offscreen or vanished behind the map. The reason: fullscreen mode moves the map element into a separate stacking context. My popper was anchored to a DOM node that wasn’t in that tree anymore.

The fix was to listen for fullscreenchange on document, swap the container ref to the map’s fullscreen container, and re-anchor the popper. Combined with styled-components for the popper styling, it became reliable. Not elegant. It works.

4. LatLngBounds drifts on wide horizontal pans

Pan east or west across a wide longitudinal span and the bounds returned by google-map-react’s onChange callback don’t match what’s on screen. Cross the 180° meridian and it gets worse. Clustering then runs against the wrong bounds, and markers vanish or duplicate near the edges of the map.

I trusted the callback for too long. Once I stopped, the fix was straightforward: recompute the visible bounds from the map’s center, viewport pixel dimensions, and zoom level. A few lines of geometry. The disappearing-marker bug was gone.

5. Grey tiles when panning vertically past the world

Pan far enough north or south and you see grey. Google Maps doesn’t wrap vertically.

I wrote a wrapper around the pan handler to clamp lat values. Forty lines, edge cases, the whole thing. Then I found the built-in option, deleted my wrapper, and felt mildly stupid:

options={{
  restriction: {
    latLngBounds: { north: 85, south: -85, west: -180, east: 180 },
    strictBounds: true,
  },
}}

Should’ve read the docs first.

What I’d use in 2026

A lot has changed in five years. If I were writing this article fresh today, I’d build the same three-component pattern (Map, Cluster, Marker) but with a different set of libraries underneath.

The Google Maps side

google-map-react isn’t actively maintained anymore. The active community has moved to @vis.gl/react-google-maps, built by the team that also makes deck.gl and react-map-gl. That’s the default Google Maps + React choice in 2026.

The big win: AdvancedMarker lets you render React directly inside the map without the portal acrobatics I needed for the fullscreen popper bug. If I were rebuilding today, gotcha #3 would mostly disappear.

Clustering

You no longer need to wire up clustering yourself. Two clean options:

  • @googlemaps/markerclusterer is the official library, maintained by Google. Works cleanly with @vis.gl/react-google-maps. Under the hood it uses supercluster, the same algorithm points-cluster was based on.
  • supercluster directly if you want full control over rendering, like I did here. Written by Vladimir Agafonkin at Mapbox. Still the fastest JS clustering algorithm I know of, and it powers basically every modern map clustering library.

If you don’t actually need Google Maps

Worth asking. Google Maps has gotten expensive, and there are good alternatives:

  • react-map-gl with MapLibre GL JS is the open-source path. MapLibre is a community fork of Mapbox GL JS that no longer ties you to Mapbox’s pricing or tiles. Production-ready in 2026. Same declarative React API.
  • Pair it with Mapbox’s vector tile clustering (it’s a first-class GeoJSON source option, not a separate library) and you get clustering for free.

For really large datasets

If you’re rendering hundreds of thousands of points and clustering isn’t enough, look at deck.gl. Its layer system (heatmap, hex grid, scatterplot) running on top of a base map handles datasets that would crush any pin-marker approach. WebGL-backed, runs on the GPU. Same team behind @vis.gl/react-google-maps and react-map-gl, so it integrates cleanly.

What about the five gotchas?

  • Selected marker state in Redux: still valid. Redux Toolkit makes it cleaner now, but the pattern is the same. Zustand or Jotai would also work fine here.
  • Density-scaled cluster bubbles: still valid. @googlemaps/markerclusterer supports custom renderers; you’d pass the same getStyle() logic.
  • Fullscreen popper: mostly solved by AdvancedMarker, which renders inside the map’s DOM tree by design.
  • Bounds drift: still real on edge cases, but @vis.gl/react-google-maps exposes the map instance cleanly, so you can call map.getBounds() directly instead of trusting the change callback.
  • Grey vertical tiles: same restriction option, same fix. Still right.

Wrap-up

The article’s stack is dated, but the architecture isn’t. Three components, Redux for selection, supercluster (or its derivatives) for the math, and a handful of real-world bugs that don’t go away just because you switch libraries.

If you’ve solved any of these differently, especially the fullscreen popper or the bounds drift, I’d love to hear how. Drop a comment or reach out.