Skip to content

useControl + MapboxDraw: Cannot read properties of undefined (reading 'get') under React 19 + Next 16 + Turbopack #2589

Description

@santoxo

Description

useControl<MapboxDraw>(...) fails on mount in a React 19 + Next.js 16 + Turbopack stack with the error:

TypeError: Cannot read properties of undefined (reading 'get')
  at draw.add(feature)

The error originates in @mapbox/mapbox-gl-draw's api.add (line 87 of src/api.js), which calls ctx.store.get(feature.id). ctx.store is undefined (not null) at the moment of the call, meaning the draw instance's onAdd lifecycle hook never executed before draw.add was invoked.

A manual useEffect that calls map.getMap().addControl(draw) and then draw.add(feature) directly (bypassing useControl entirely) does not exhibit the bug under the same stack. This narrows the bug to useControl's lifecycle handling specifically, not to mapbox-gl-draw or the broader pattern of mounting mapbox-gl primitives from React effects.

Environment

Diagnostic strategies attempted (all failed)

We hit the error during a feature-flagged editor migration to mapbox-gl-draw. Five fix iterations across four mitigation strategies, all with the error appearing at the same call site (draw.add(feature) in the parent's hydration useEffect):

  1. Sync setDrawReady(true) from props.onCreate inside useControl's factory. Errored: Cannot update a component while rendering a different component (factory runs in useMemo during render, and the parent's setState from inside the child's render-phase factory is illegal).
  2. queueMicrotask(() => setDrawReady(true)). First error cleared. New error surfaced: Cannot read properties of undefined (reading 'get') from draw.add. Also fixed a second error (You must provide a featureId to enter direct_select mode) by switching the constructor's defaultMode from a custom direct_select-extending mode to simple_select.
  3. Added a drawSourcesReady gate driven by a one-shot 'idle' event listener registered inside onLoad, in addition to mapLoaded and drawReady. Hypothesis: the map's idle event fires only after both react-map-gl's onLoad and mapbox-gl-draw's internal load-handler complete, so by then _ctx.store should be initialised. Outcome: error persisted at the same site.
  4. Ported to the documented useControl(onCreate, onAdd, onRemove) third-overload recipe. Factory returns the MapboxDraw instance only; event wiring and parent-notification (props.onCreate(draw)) moved into onAdd so the parent receives the draw instance only after useControl's useEffect has invoked map.addControl(draw) and triggered MapboxDraw.onAdd(map) to set _ctx.store. Outcome: error persisted at the same site, despite the lifecycle ordering being structurally correct on paper.

After the fifth iteration we abandoned the migration and ran an empirical PoC: a manual useEffect-based mount that bypasses useControl. The PoC succeeds 10/10 across hard-refresh and client-side-navigation stress cycles under the same stack.

Workaround (manual addControl in useEffect)

This pattern works in the same stack where useControl<MapboxDraw> fails. Posted as data, not as a recommendation for the upstream API:

"use client";
import { useEffect, useRef, useState } from "react";
import Map, { type MapRef } from "react-map-gl";
import MapboxDraw from "@mapbox/mapbox-gl-draw";
import "mapbox-gl/dist/mapbox-gl.css";
import "@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css";

const FEATURE: GeoJSON.Feature = {
  type: "Feature",
  properties: {},
  geometry: {
    type: "LineString",
    coordinates: [[5.32, 60.39], [5.4, 60.0], [5.6, 59.5], [5.73, 58.97]],
  },
};

export default function ManualPoc() {
  const mapRef = useRef<MapRef>(null);
  const [mapLoaded, setMapLoaded] = useState(false);
  const [status, setStatus] = useState<string>("idle");

  useEffect(() => {
    if (!mapLoaded) return;
    const map = mapRef.current?.getMap();
    if (!map) return;
    let draw: MapboxDraw | null = null;
    try {
      draw = new MapboxDraw({ displayControlsDefault: false });
      map.addControl(draw);                  // synchronous; draw.onAdd runs here
      const ids = draw.add(FEATURE);          // succeeds
      setStatus(`success: id=${ids[0]}`);
    } catch (e) {
      setStatus(`error: ${(e as Error).message}`);
    }
    return () => {
      if (draw && map.hasControl(draw)) {
        try { map.removeControl(draw); } catch {}
      }
    };
  }, [mapLoaded]);

  return (
    <>
      <div style={{ padding: 12, fontFamily: "monospace" }}>{status}</div>
      <Map
        ref={mapRef}
        mapboxAccessToken={process.env.NEXT_PUBLIC_MAPBOX_TOKEN}
        initialViewState={{ longitude: 5.5, latitude: 59.7, zoom: 6 }}
        style={{ width: "100%", height: "calc(100vh - 40px)" }}
        mapStyle="mapbox://styles/mapbox/light-v11"
        onLoad={() => setMapLoaded(true)}
      />
    </>
  );
}

Reproducible failing example

The same component but using useControl<MapboxDraw>(onCreate, onAdd, onRemove) (the third-overload recipe) fails. Minimal failing pattern:

function DrawControl({ onCreate }: { onCreate: (d: MapboxDraw) => void }) {
  const drawInstanceRef = useRef<MapboxDraw | null>(null);
  useControl<MapboxDraw>(
    () => {
      const draw = new MapboxDraw({ displayControlsDefault: false });
      drawInstanceRef.current = draw;
      return draw;
    },
    ({ map }) => {
      const draw = drawInstanceRef.current;
      if (draw) onCreate(draw);
    },
    () => {},
  );
  return null;
}

function ParentMap() {
  const drawRef = useRef<MapboxDraw | null>(null);
  const [drawReady, setDrawReady] = useState(false);
  const [mapLoaded, setMapLoaded] = useState(false);

  useEffect(() => {
    if (!drawReady || !mapLoaded) return;
    drawRef.current?.add({
      type: "Feature", properties: {},
      geometry: { type: "LineString", coordinates: [[0,0],[1,1]] },
    }); // throws: Cannot read properties of undefined (reading 'get')
  }, [drawReady, mapLoaded]);

  return (
    <Map onLoad={() => setMapLoaded(true)}>
      <DrawControl onCreate={(d) => { drawRef.current = d; setDrawReady(true); }} />
    </Map>
  );
}

Hypothesised root cause

The empirical asymmetry (useEffect-driven addControl works; useControl-driven addControl does not) under the same React/Next/Turbopack stack suggests the bug lives in useControl's useMemo + useEffect choreography rather than in mapbox-gl-draw or in the broader pattern.

Two non-exclusive hypotheses:

  1. useMemo factory orphans under React 19 StrictMode. React 19 invokes useMemo's calculator twice in StrictMode dev (the second result is returned). If the first factory call constructs a MapboxDraw that the parent ever holds a reference to (via a side effect in the factory body, or via some other capture path), and that reference's onAdd is never called by useControl's useEffect (which uses the second result), then draw.add(...) on the first instance throws because _ctx.store is undefined.

  2. Effect-ordering interleaving with Turbopack hot-mount cycles. Issues [Bug] Marker crashes with "appendChild" error during rapid client-side navigation (React 19 / Next.js 16) #2584 and [Bug] Marker crashes with "appendChild" on Activity reappear when Next.js cacheComponents is enabled (production-only) #2588 document the same family of failures across Marker (addTo), Source / Layer (addSource / addLayer), where the new mount's effects fire before the previous mount's cleanup runs. useControl's lifecycle may have a similar interleave window.

Manual useEffect mount avoids both: the draw instance is created in setup, used in setup, and torn down in cleanup, all in a single closure. There is no orphaned reference held by the parent and no separate factory-vs-effect timing.

Related open issues

The four issues plus this one suggest a class of failures, not isolated bugs. The common factor across all five: react-map-gl's effect-mounted child primitives misbehave under React 19's StrictMode + Next.js 16's Turbopack and Activity machinery.

Background

Filing as community contribution from a Front Carbon (CCS planning B2B SaaS) Editor 2.0 migration that abandoned useControl<MapboxDraw> after five iterations and pivoted to manual useEffect-based mount. The minimal reproducible code above is from our internal sandbox PoC (10/10 green for the manual pattern; 0/5 green for the useControl pattern under the same stack). Happy to provide a full minimal-reproduction repository if it helps narrow down the root cause.

Not blocking on a fix from our side; the workaround is acceptable for our use case. Posting in case it helps the maintainers correlate this with the four related open issues into a single root-cause investigation.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions