Zustand as the bridge between Three.js and MapLibre: one store, two render trees
title: "Zustand as the bridge between Three.js and MapLibre: one store, two render trees" slug: zustand-three-maplibre-bridge category: web-development audience: CTOs, senior engineers, fellow practitioners status: draft
Zustand as the bridge between Three.js and MapLibre: one store, two render trees
Five THREE handles and one MapLibre
Mapliving in the same Zustand store. Why a flat global handle store is the cleanest path through a 3D-on-2D geospatial viewer — and where the pattern leaks.
A 3D-on-2D geospatial viewer has two render trees that do not know about each other. MapLibre owns the WebGL canvas, the tile sources, and the map camera. Three.js owns its own scene graph, its own PerspectiveCamera, its own WebGLRenderer. Both want to write to the same pixels. Both want to react to the same pointer events. React, sitting on top, wants to pretend everything is a tree of components with props.
The thing that fails first when you try to make this work is component composition. You cannot prop-drill a THREE.Scene through twelve layers of UI to a panel that wants to drop a GLTF into it. You cannot lift state up either, because the scene is not state — it is a long-lived imperative object whose lifecycle is owned by a MapLibre custom layer, not by React.
The working pattern in this Next.js viewer is a single flat Zustand store that holds the live handles: globalGL, globalScene, globalCamera, globalRaycaster, globalMap, globalControl, plus a viewerCameraMatrix slot for the per-frame projection matrix coming from MapLibre. Every UI panel, every controller, every WebSocket service reads from that store. There is exactly one source of truth, and it is not React.
This piece walks through that store, the contract it enforces, the consumer code that depends on it, and the three places the pattern starts to bleed.
The decision this pattern is for
The decision is: where do non-React objects live in a React app that integrates two graphics engines?
You have three options. You can hide them inside refs and a context provider, which works until you need to read them from a sibling subtree. You can stuff them on window, which works until you have two viewer instances on the same page. Or you can put them in a Zustand store, accept that they are mutable references rather than serializable state, and let the rest of the app subscribe to "the handle is now available".
The third option is what is live in components/viewer/store.ts. It is not a generic application state store — there is no user, no theme, no API cache. It is a registry of long-lived graphics objects, plus a tiny amount of project metadata. That separation matters: when a panel reads globalMap, it knows the value is either null (not ready) or a real maplibregl.Map it can call methods on. There is no reducer, no derived state, no selector chain in between.
The framework: five steps the store enforces
- Define the store as a flat record of nullable handles plus their setters. No nesting, no slices, no middleware.
- Initialize each handle from the place that constructs the underlying object — the MapLibre
useEffect, the Three.js custom layer'sonAdd. Not from a "viewer provider". - Consumers subscribe with
useViewerStore()for reactive reads, oruseViewerStore.getState()for one-shot imperative reads from non-React code (a WebSocket handler, a controller class). - The per-frame projection matrix is the only fast-changing value the store carries. Everything else changes at most once per viewer lifetime.
- Cleanup is the consumer's job, not the store's. The store does not unset handles on unmount, because consumers cannot tell the difference between "real teardown" and "React strict-mode double-invoke".
Each step matches a constraint that the two render trees impose. Skip a step and the symptom is always the same: a panel reads globalScene while setGlobalScene has not run yet, throws, and the user sees a blank map with a console full of Cannot read properties of null.
The store, typed
Here is the actual store contract from components/viewer/store.ts. The interface is intentionally flat:
// components/viewer/store.ts
export const useViewerStore = create<{
globalGL: THREE.WebGLRenderer | null
setGlobalGL: (globalGL: THREE.WebGLRenderer) => void
globalContext: WebGLRenderingContext | null
setGlobalContext: (globalContext: WebGLRenderingContext) => void
globalCamera: THREE.Camera | null
setGlobalCamera: (globalCamera: THREE.Camera) => void
globalScene: THREE.Scene | null
setGlobalScene: (globalScene: THREE.Scene) => void
globalControl: any | null
setGlobalControl: (globalControls: any) => void
globalRaycaster: THREE.Raycaster | null
setGlobalRaycaster: (globalRaycaster: THREE.Raycaster) => void
globalMap: maplibregl.Map | null
setGlobalMap: (globalMap: maplibregl.Map) => void
viewerCameraMatrix: THREE.Matrix4 | null
setViewerCameraMatrix: (viewerCameraMatrix: THREE.Matrix4) => void
// project metadata + websocket + UI mode flags
projectId?: string
setProjectId: (projectId: string) => void
websocketClient?: any
setWebsocketClient: (websocketClient: any) => void
isDrawingMode: boolean
setIsDrawingMode: (isDrawing: boolean) => void
}>((set, get) => ({
globalGL: null,
setGlobalGL: (globalGL) => set({ globalGL }),
globalScene: null,
setGlobalScene: (globalScene) => set({ globalScene }),
globalCamera: null,
setGlobalCamera: (globalCamera) => set({ globalCamera }),
globalRaycaster: null,
setGlobalRaycaster: (globalRaycaster) => set({ globalRaycaster }),
globalMap: null,
setGlobalMap: (globalMap) => set({ globalMap }),
viewerCameraMatrix: null,
setViewerCameraMatrix: (viewerCameraMatrix) => set({ viewerCameraMatrix }),
isDrawingMode: false,
// ...
}))
A few things to notice. Every handle is T | null, never T | undefined. Every setter takes exactly the type it stores — no partial updates, no merge semantics. The store accepts that globalControl is any because MapLibre control objects and Three.js EventDispatcher instances are structurally incompatible and both real targets at different times in the codebase. The pragmatic call: keep the type loose where the contract is genuinely loose, instead of inventing a fake union.
There is no Zustand middleware. No persist, no devtools, no subscribeWithSelector. Persisting a THREE.WebGLRenderer to localStorage is nonsense. Logging every change of a Matrix4 would saturate the console. The store stays naked on purpose.
How the handles get populated
The MapLibre handle is set inside the map's mount effect. components/viewer/MaplibreMapViewer.tsx constructs the Map instance, patches addLayer and removeLayer to fire custom events, then writes the instance into the store:
// components/viewer/MaplibreMapViewer.tsx
export default function MaplibreMapViewer() {
const { setGlobalMap, setGlobalControl, isDrawingMode } = useViewerStore()
const mapContainer = useRef<HTMLDivElement>(null)
useEffect(() => {
const map = new maplibregl.Map({
container: mapContainer.current!,
center: [46.7219, 24.6877],
zoom: 13,
pitch: 45,
bearing: -10,
style: { /* base raster + sky */ },
maxZoom: 18,
maxPitch: 85
})
map.addControl(new maplibregl.NavigationControl({ visualizePitch: true }), 'top-right')
// patch addLayer / removeLayer to fire 'layer-added' / 'layer-removed'
// ... (see file)
setGlobalMap(map)
map.on('load', async () => {
await map.registerController(new MouseEventController())
await map.registerController(new KeyboardEventController())
await map.registerController(new MousePointerController())
await map.registerController(new MemoryController())
})
return () => { map.remove() }
}, [])
return (/* ... */)
}
The Three.js handles get populated from the other side — inside a custom map layer's onAdd callback. In the Azure-Maps variant of the viewer (components/viewer/AzureMapViewer.tsx), the WebGL layer constructs its own THREE.Scene, THREE.PerspectiveCamera, and THREE.WebGLRenderer and pushes all four into the same store the MapLibre side writes to:
// components/viewer/AzureMapViewer.tsx
onAdd (map, gl) {
this.map = map
this.scene = new THREE.Scene()
this.camera = new THREE.PerspectiveCamera(
45,
map.getCanvas().width / map.getCanvas().height,
0.1,
1000
)
this.camera.position.z = 1000
this.camera.up.set(0, 0, 1) // Z-up for Three.js
const dirLight = new THREE.DirectionalLight(0xffffff)
dirLight.position.set(0, 70, 100).normalize()
this.scene.add(dirLight)
const ambientLight = new THREE.AmbientLight(0x808080)
this.scene.add(ambientLight)
this.renderer = new THREE.WebGLRenderer({
canvas: map.getCanvas(),
context: gl,
preserveDrawingBuffer: true
})
this.renderer.autoClear = false
setGlobalScene(this.scene)
setGlobalCamera(this.camera)
setGlobalGL(this.renderer)
setGlobalContext(this.renderer.getContext())
setGlobalRaycaster(new THREE.Raycaster())
}
That single block is the bridge. The Three.js WebGLRenderer shares the map's WebGL context (context: gl) — there is no second canvas, no second context — and the scene/camera/renderer/raycaster all land in the same store the rest of the React tree reads from.
The matrix flow — where two cameras become one
The hard part of stitching Three.js to a map library is not the scene graph. It is the camera. MapLibre's camera lives in mercator-projected space with its own field of view, pitch, bearing, and altitude. Three.js wants a 4x4 projection matrix in its own coordinate system.
The map library hands you a matrix: number[] on every frame inside the custom layer's render callback. The custom layer's job is to convert that matrix into Three's coordinate convention, multiply by the model placement matrix, assign it to camera.projectionMatrix, then call renderer.render(scene, camera). The viewerCameraMatrix slot in the Zustand store exists so that any other consumer — a hit-test, a debug overlay, a custom shader — can read the same matrix without re-deriving it.
The render hook looks like this:
// components/viewer/AzureMapViewer.tsx
render (gl, matrix) {
const md = this.modelDetails
const rotationX = new THREE.Matrix4().makeRotationAxis(
new THREE.Vector3(1, 0, 0), md.rotateX
)
const rotationY = new THREE.Matrix4().makeRotationAxis(
new THREE.Vector3(0, 1, 0), md.rotateY
)
const rotationZ = new THREE.Matrix4().makeRotationAxis(
new THREE.Vector3(0, 0, 1), md.rotateZ
)
const m = new THREE.Matrix4().fromArray(matrix)
const l = new THREE.Matrix4()
.makeTranslation(
md.mercatorOrigin[0],
md.mercatorOrigin[1],
md.mercatorOrigin[2]
)
.scale(new THREE.Vector3(md.scale, -md.scale, md.scale))
.multiply(rotationX)
.multiply(rotationY)
.multiply(rotationZ)
this.camera.projectionMatrix.elements = matrix
this.camera.projectionMatrix = m.multiply(l)
this.renderer!.resetState()
this.renderer!.render(this.scene, this.camera)
this.renderer!.resetState()
this.map!.triggerRepaint()
}
The resetState() calls before and after renderer.render are not decorative. The Three.js renderer and the map library are both touching the same GL context, and Three caches WebGL state aggressively. Without resetState(), Three's blending mode bleeds into the next map-layer draw and you get tinted tiles.
triggerRepaint() at the end of the frame is what keeps the render loop alive while the map is otherwise idle. If your Three.js scene contains an animated GLTF, the map will not redraw on its own — you have to push it.
Consumers that read from the store
The payoff of the store-as-registry pattern shows up in consumer code. A drawing panel does not need to know how the map was constructed. It pulls the live Map instance out of the store and acts on it. components/viewer/ui/BottomMenu/partials/DimensionSwitch.tsx:
// components/viewer/ui/BottomMenu/partials/DimensionSwitch.tsx
export default function DimensionSwitch() {
const { globalMap } = useViewerStore()
const [component, setComponent] = useState<DimensionComponent>()
const [is3D, setIs3D] = useState(false)
useEffect(() => {
if (!globalMap) return
const comp = new DimensionComponent(globalMap)
comp.activate()
setComponent(comp)
const unsub = comp.onDimensionChanged((state) => setIs3D(state))
return () => { unsub(); comp.destroy() }
}, [globalMap])
if (!component) return null
// ... toggle button
}
The if (!globalMap) return guard is the whole reason every handle in the store is T | null rather than T | undefined. A null guard is one statement. The useEffect re-runs when globalMap flips from null to a real instance, and the panel only mounts its imperative DimensionComponent once the map is ready.
Non-React code uses the same store through getState() instead of the hook. The WebSocket client at components/viewer/services/ProjectWSClient.ts is a static class — there is no component to hold its state — and it pulls the live scene directly:
// components/viewer/services/ProjectWSClient.ts
public static connect(projectId: string): void {
ProjectWSClient.projectId = projectId
if (ProjectWSClient.socket) {
ProjectWSClient.socket.close()
}
const globalScene = useViewerStore.getState().globalScene
ProjectWSClient.scene = globalScene
const wsBaseUrl = process.env.NEXT_PUBLIC_WS_URL || 'ws://localhost:3000'
const socket = new WebSocket(`${wsBaseUrl}/projects/${projectId}`)
ProjectWSClient.socket = socket
useViewerStore.setState({ websocketClient: ProjectWSClient })
// ...
}
public static save(): void {
const globalScene = useViewerStore.getState().globalScene
if (!globalScene) {
console.warn('[WS] Save failed: Global scene is not set')
return
}
const message = {
type: 'project:save',
payload: {
name: 'My Scene',
timestamp: new Date().toISOString(),
scene: globalScene.toJSON()
}
}
ProjectWSClient.socket?.send(JSON.stringify(message))
}
globalScene.toJSON() works because the scene is a real THREE.Scene — the same object the custom layer's onAdd constructed and setGlobalScene parked in the store. There is no serialization-then-rehydration step. The class reaches into the same registry the UI reads from.
The same pattern runs in reverse on project:init: the server sends a serialized scene, the client parses it with new THREE.ObjectLoader().parse(sceneJSON), then it transplants every child into the live globalScene. The scene reference does not change — only its children do — so every panel that already holds a reference to globalScene keeps working without a re-render.
Where the pattern leaks
Three places.
Render-tree teardown is dishonest. Nothing in the store ever sets globalScene back to null. If a user navigates between two viewer routes inside the same SPA, the second mount runs setGlobalScene(newScene) and the previous scene becomes orphaned but is still held by anything that grabbed it through getState() before the swap. The WebSocket client is the worst offender — it captures ProjectWSClient.scene = globalScene once and holds it until the next connect() runs. For a single long-lived viewer that is fine. For a multi-project switcher, it is a memory bug waiting for someone to file it.
any types hide a real divergence. globalControl is typed any because two map backends (MapLibre and the Azure variant) write incompatible control objects into the same slot. That is honest about the current state of the code, but it means an IDE refactor that renames a method on one backend will silently miss every call site that reaches the control through the store. A discriminated union ({ kind: 'maplibre', control: ... } | { kind: 'azure', control: ... }) would catch it. The current code does not.
Reactive re-renders fire for changes that should be quiet. setViewerCameraMatrix is called on every frame the map redraws. Every component that does const { viewerCameraMatrix } = useViewerStore() will re-render every frame. There is no consumer in this codebase that currently does that, but the trap is open — the first time someone writes a debug overlay that subscribes to the matrix, the React profiler will light up. The fix is useViewerStore(state => state.viewerCameraMatrix) with a shallow comparator, or moving the matrix out of the store entirely into a per-frame ref. Both are easy. Neither is enforced today.
Trade-off
The trade-off this pattern accepts: you give up React's mental model in exchange for direct access. The Zustand store here is not "state" in the Redux sense. It is a typed bag of mutable references. Components that read from it cannot be tested in isolation by passing props — they need a real THREE.Scene and a real maplibregl.Map, or a stub that lies about both. Storybook-driven development of viewer panels is harder, not easier, because of this.
The alternative — pushing each handle through context and ref-forwarding — keeps the React model intact but multiplies provider boilerplate by the number of handles and makes consumer code longer at every call site. For a viewer with one map and one scene per route, the flat store wins on legibility. For a generic 3D framework that hosts many viewers, the trade flips.
Business impact
A 3D-on-2D viewer is the kind of feature that takes months to prototype and years to maintain. The reason this codebase can ship feature panels — extrusion analysis, layer filters, dimension switching, scenario analysis modes — at the cadence the recent commits show (feat: multi layer analysis, feat: layer scrolls, fix: preventing removing analysis layers) is that each panel is a thin React leaf reading two or three handles out of one store. New features do not negotiate with a viewer framework. They useViewerStore() and call MapLibre methods directly. That is the speed lever. The trade is the leaks above — and the team has to know which ones matter when.
What to do next
If you are bridging two graphics engines through React, before you build the store, write down every handle that has to be shared, the lifetime of each one (one per app, one per route, one per frame), and which consumers are React and which are not. Then look at the table. If half the handles are per-frame, a Zustand store is the wrong tool — use a ref. If half the consumers are non-React, a context provider is the wrong tool — use a store. If both halves are mixed, the pattern in components/viewer/store.ts is the answer, and the leaks above are the price you will pay.
The check: can a sibling panel three subtrees away call a method on the live map without a single piece of prop-drilling? If yes, the store is doing its job.
Related Articles
Same CategoryComments (0)
Newsletter
Stay updated! Get all the latest and greatest posts delivered straight to your inbox