useControl + MapboxDraw: Cannot read properties of undefined (reading 'get') under React 19 + Next 16 + Turbopack
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 8.5k
- Forks
- 1.4k
- Avg merge
- 5d 17h
- Merged PRs (30d)
- 3
Description
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
react-map-gl@^7.1.9(issue likely also affects@vis.gl/react-mapbox@8.xper related issues below)@mapbox/mapbox-gl-draw@^1.5.1mapbox-gl@^3.22.0react@19.2.3next@16.1.7(App Router, Turbopack dev mode, default StrictMode)- Windows 11 / Chrome (also reproduced on macOS per issue #2584's reporter)
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):
- Sync
setDrawReady(true)fromprops.onCreateinsideuseControl's factory. Errored:Cannot update a component while rendering a different component(factory runs inuseMemoduring render, and the parent'ssetStatefrom inside the child's render-phase factory is illegal). queueMicrotask(() => setDrawReady(true)). First error cleared. New error surfaced:Cannot read properties of undefined (reading 'get')fromdraw.add. Also fixed a second error (You must provide a featureId to enter direct_select mode) by switching the constructor'sdefaultModefrom a custom direct_select-extending mode tosimple_select.- Added a
drawSourcesReadygate driven by a one-shot'idle'event listener registered insideonLoad, in addition tomapLoadedanddrawReady. Hypothesis: the map'sidleevent fires only after both react-map-gl'sonLoadand mapbox-gl-draw's internal load-handler complete, so by then_ctx.storeshould be initialised. Outcome: error persisted at the same site. - Ported to the documented
useControl(onCreate, onAdd, onRemove)third-overload recipe. Factory returns theMapboxDrawinstance only; event wiring and parent-notification (props.onCreate(draw)) moved intoonAddso the parent receives the draw instance only afteruseControl'suseEffecthas invokedmap.addControl(draw)and triggeredMapboxDraw.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:
-
useMemofactory orphans under React 19 StrictMode. React 19 invokesuseMemo's calculator twice in StrictMode dev (the second result is returned). If the first factory call constructs aMapboxDrawthat the parent ever holds a reference to (via a side effect in the factory body, or via some other capture path), and that reference'sonAddis never called byuseControl'suseEffect(which uses the second result), thendraw.add(...)on the first instance throws because_ctx.storeisundefined. -
Effect-ordering interleaving with Turbopack hot-mount cycles. Issues #2584 and #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
- #2413 —
useControl + MapboxDrawbroke on Next 14.1 → 14.2 upgrade. Open since 2024-07-08, no maintainer response. - #2584 —
MarkercrashesCannot read properties of undefined (reading 'appendChild')on rapid client-side navigation in React 19 + Next 16. Open since 2026-04-03; confirmed by a second reporter. - #2588 —
Markercrashes oncacheComponentsActivity reappear in React 19 + Next 16. - #2410 — React 19 +
Source/LayerFragment-prop warnings.
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.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with the useControl implementation, especially its useMemo/useEffect lifecycle, and compare it with the manual useEffect addControl sequence in the reproducible examples. Check the MapboxDraw api.js call at line 87 and the reported React 19 StrictMode/Turbopack behavior. Done means the failing example no longer reaches draw.add with an undefined ctx.store.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- next.js, react, typescript
- Domain
- frontend, web-dev
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100