React 18 and Mapbox GL JS: Synchronizing External State with Imperative Camera APIs
0 reputation · 17 Sept 2026, 09:25 UTC
0 reputation · 17 Sept 2026, 09:25 UTC
Integrating Mapbox GL JS v2+ within a React 18 environment introduces a conflict between React's declarative state management and Mapbox's imperative API. While wrappers like react-map-gl provide a useMap hook, maintaining a single source of truth for the viewport (center, zoom, and bearing) becomes complex when using external state managers such as Redux or Zustand.
In React 18 Strict Mode, the double-invocation of effects can lead to race conditions where the map instance is recreated or updated before the previous instance has fully initialized. This often results in synchronization drift, where the external state and the actual map camera position diverge during rapid updates or component re-mounts.
map.on('load') callbacks.What is the recommended pattern for synchronizing an external state store with the Mapbox camera to prevent drift during React 18's double-mount cycle? How should the cleanup phase be handled to ensure imperative camera updates do not conflict with the declarative state lifecycle?
33775 reputation · 17 Sept 2026, 19:40 UTC
The reliable approach combines a ref for the Mapbox instance, a mounted flag ref, and a subscription to the external store that only applies changes after map.loaded() resolves. This prevents drift during React 18's development-only double-mount cycle.
import { useEffect, useRef } from 'react';
import mapboxgl from 'mapbox-gl';
import { useStore } from './store'; // Zustand/Redux hook
export function useMapSync(containerRef) {
const mapRef = useRef(null);
const mountedRef = useRef(false);
const { center, zoom, bearing, pitch, setViewport } = useStore();
useEffect(() => {
if (!containerRef.current || mapRef.current) return;
// Read initial values once at mount time — no stale closure
const initialViewport = useStore.getState();
const map = new mapboxgl.Map({
container: containerRef.current,
center: initialViewport.center,
zoom: initialViewport.zoom,
bearing: initialViewport.bearing ?? 0,
pitch: initialViewport.pitch ?? 0,
style: 'mapbox://styles/mapbox/streets-v12',
});
mapRef.current = map;
mountedRef.current = true;
// Wait for map to be ready before accepting external updates
map.once('load', () => {
if (!mountedRef.current) return; // Guard against cleanup race
// Map is ready; external subscriptions can now apply safely
});
// Throttled emission from map → store (avoid 'move' flood)
const onMoveEnd = () => {
if (!mountedRef.current) return;
const { center, zoom, bearing, pitch } = map.getContainer() ? map.getViewport() : {};
if (center) setViewport({ center, zoom, bearing, pitch });
};
map.on('moveend', onMoveEnd);
return () => {
mountedRef.current = false; // Synchronously block new imperative calls
map.off('moveend', onMoveEnd);
map.off('load', () => {});
map.remove();
mapRef.current = null;
};
}, [containerRef, setViewport]);
// Subscribe to external store → map (imperative)
useEffect(() => {
if (!mountedRef.current || !mapRef.current?.loaded()) return;
mapRef.current.jumpTo({ center, zoom, bearing, pitch });
}, [center, zoom, bearing, pitch]);
return mapRef;
}
mounted=true. React immediately unmounts (cleanup runs, mounted=false, map removed). Second mount creates fresh instance. The flag ensures no external updates apply to the orphaned first instance.map.on('load'): Initial viewport is read via useStore.getState() at mount time, not captured in closure. The subscription effect reads current store values on each change.map.remove() and store updates: mountedRef.current is checked synchronously before any jumpTo/flyTo call.mountedRef.current = false first — blocks in-flight store updates from touching the dying instance.moveend, load, any custom).map.remove() to release WebGL context and DOM handlers.<React.StrictMode>; no console errors on hot reload.setInterval changing center every 200 ms); camera follows without "map not loaded" errors.One diagnostic detail: Are you using react-map-gl v7+ or raw Mapbox GL JS? The wrapper's useMap hook changes where the loaded() promise lives, but the mounted-flag guard pattern remains the same.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.