Skip to content

Use Maptoolkit Maps in React

A map in React is a useRef for the container, a useRef for the map instance and one useEffect that creates and destroys it. The map is not state, it is not a prop, and it never belongs in useState.

The component

import { useEffect, useRef } from "react";
import { Map } from "@maptoolkit/maps";
import "@maptoolkit/maps/css";

export default function MaptoolkitMap() {
  const containerRef = useRef(null);
  const mapRef = useRef(null);

  useEffect(() => {
    if (mapRef.current) return;

    mapRef.current = new Map({
      container: containerRef.current,
      apiKey: process.env.REACT_APP_MAPTOOLKIT_KEY,
      center: [16.37, 48.21],
      zoom: 12,
    });

    return () => {
      mapRef.current?.remove();
      mapRef.current = null;
    };
  }, []);

  return <div ref={containerRef} style={{ width: "100%", height: "400px" }} />;
}

Four details in that snippet matter more than they look.

The container gets a height. A div with no height is zero pixels tall, the map initializes into nothing and you get a blank page with no error. This is the single most common “the map does not show up” report, and it is never the map’s fault.

The effect’s dependency array is empty. Anything in that array re-runs the effect, which tears down and rebuilds the map. A map rebuilt on every render loses the user’s position on every keystroke elsewhere in the form.

The cleanup calls remove(). Without it, navigating away from the page leaks the WebGL context. Browsers cap the number of live contexts at around 8 to 16; past the cap, existing maps go blank rather than throwing.

The map is in a ref, not state. Putting it in useState triggers a re-render on every update and invites React into an object it should not be diffing.

StrictMode creates two maps

In development, React 18 and later deliberately mount, unmount and remount every component once, to surface missing cleanup. The effect above therefore runs twice, and without the if (mapRef.current) return; guard you get two maps stacked in one container: doubled controls, doubled attribution and events firing twice.

It only happens in development, which makes it worse rather than better, because the guard that fixes it is easy to leave out and the symptom never reaches production for you to notice. The guard plus a cleanup that nulls the ref handles both the StrictMode remount and a real unmount.

Changing the map after it exists

Once the map is created, drive it with imperative calls from effects that depend on the values you care about. Do not recreate it.

useEffect(() => {
  mapRef.current?.flyTo({ center: selected.coords, zoom: 14 });
}, [selected]);

The same applies to markers, sources and layers: add them in a load handler or in an effect that runs after creation, and remove them in that effect’s cleanup.

useEffect(() => {
  const map = mapRef.current;
  if (!map) return;

  const marker = new Marker().setLngLat(selected.coords).addTo(map);
  return () => marker.remove();
}, [selected]);

Waiting for the style

Anything that touches sources or layers has to wait for the style to load, or it throws “Style is not done loading”. Inside a component that means guarding on the event rather than assuming:

map.on("load", () => {
  map.addSource("route", { type: "geojson", data: routeGeoJSON });
  map.addLayer({ id: "route", type: "line", source: "route" });
});

If the map may already be loaded by the time your effect runs, check map.isStyleLoaded() first and skip the listener.

Where to put the API key

Bundlers inline environment variables at build time, so anything you reference ends up in the JavaScript the browser downloads. A map key is a public credential by nature, since the browser has to send it, so this is expected rather than a leak, but it means the key is readable by anyone who opens the network tab. Restrict it to your domains rather than relying on it being hidden.

Next steps