How GeoLibre's Store-Driven Architecture Manages State Between the UI and MapLibre
GeoLibre uses a single Zustand store (useAppStore) as the source of truth, with a thin MapController in MapCanvas.tsx observing store slices to drive MapLibre and feeding map events back into the store for deterministic two-way synchronization.
GeoLibre's architecture eliminates direct coupling between UI components and the MapLibre rendering engine. By centralizing all mutable state in a temporal Zustand store and abstracting map operations through a dedicated controller, the codebase achieves predictable data flow, full undo/redo support, and clean separation of concerns.
Core Architecture: The Zustand Store as Source of Truth
All application state lives in packages/core/src/store.ts, which exports the useAppStore hook. This store holds every piece of mutable data: basemap configuration, layer definitions, map view parameters, selection state, and UI modes.
Components never manipulate MapLibre directly. Instead, they invoke typed actions exposed by the store:
setMapView(view, persist?)– update camera positionsetBasemapStyleUrl(url)– switch basemap stylessetBasemapOpacity(opacity)– adjust basemap transparencyselectLayer(layerId)– change active layer selectionaddLayer(layer)– append new data layerssetPointerElevation(elevation)– update cursor height readings
The store is wrapped with zundo (defined in packages/core/src/history.ts), making every state change undoable except for transient values like pointerElevation or live camera updates during animations.
The MapController: Bridging Store and MapLibre
The actual MapLibre instance is encapsulated in MapController, instantiated inside packages/map/src/MapCanvas.tsx. This component serves as the single integration point between reactive store state and imperative map operations.
How Store Changes Reach the Map
MapCanvas subscribes to relevant store slices via useAppStore selectors. When these values change, useEffect hooks translate them into controller method calls:
| Store Slice | Controller Method | Effect |
|---|---|---|
basemapStyleUrl |
setStyle(url) |
Replaces entire map style |
basemapVisible |
setBasemapVisible(bool) |
Toggles basemap layer visibility |
basemapOpacity |
setBasemapOpacity(number) |
Adjusts raster opacity |
selectedLayerId / selectedFeatureIds |
highlightFeature(layer, ids) |
Applies selection styling |
layers array |
Various source/layer operations | Synchronizes data layers |
// From packages/map/src/MapCanvas.tsx – basemap style synchronization
useEffect(() => {
const map = controller.current?.getMap();
if (!map) return;
map.once('style.load', () => {
const state = useAppStore.getState();
controller.current?.setBasemapVisible(state.basemapVisible);
controller.current?.setBasemapOpacity(state.basemapOpacity);
controller.current?.highlightFeature(
state.layers.find(l => l.id === state.selectedLayerId),
resolveHighlightIds(state)
);
onControllerReadyRef.current?.();
});
controller.current?.setStyle(basemapStyleUrl);
}, [basemapStyleUrl]);
How Map Events Flow Back to the Store
User interactions with the map—panning, zooming, clicking—are captured by MapLibre event listeners and written back to the store:
// From packages/map/src/MapCanvas.tsx – view synchronization
map.on('moveend', () => {
// Prevent feedback loops during story-map presentations or flight animations
if (useAppStore.getState().ui.storymapPresenting) return;
setMapView(mc.readView(), true); // persist stable view
setCameraAltitude(mc.readCameraAltitude());
});
The moveend event (and projectiontransition for globe projections) provides stable camera states. Intermediate frames during gestures are not persisted, avoiding store churn and unnecessary re-renders.
UI Component Patterns: Reading and Mutating Store State
UI components remain completely agnostic of MapLibre. They consume slices of useAppStore and invoke actions:
// Basemap selector component pattern
const basemapUrl = useAppStore(state => state.basemapStyleUrl);
const setBasemap = useAppStore(state => state.setBasemapStyleUrl);
<select value={basemapUrl} onChange={e => setBasemap(e.target.value)}>
<option value="https://basemap.org/earth">Earth</option>
<option value="https://basemap.org/moon">Moon</option>
</select>
This pattern applies consistently across toolbars, layer panels, property inspectors, and modal dialogs. No component imports MapLibre types or references the map instance directly.
Special Modes and Conditional Synchronization
GeoLibre respects special operating modes that temporarily break the bidirectional flow:
- Story-map presenting: Camera is controlled by scripted keyframes;
moveendevents are ignored - Flight simulation: Smooth camera animations override user navigation; store only receives final state
- Collaboration (future): Remote user cursor positions may suppress local view updates
These guards prevent race conditions where the map and store would fight for control of the camera.
Layer and Feature Management
Data layers follow the same store-driven pattern. The layers array in the store contains metadata and GeoJSON sources. When modified:
MapCanvasdetects the change viauseAppStore(state => state.layers)- The controller compares new and existing layers, adding/removing MapLibre sources and layers as needed
packages/map/src/geojson-loader.tsgenerates deterministic source and layer IDs to maintain referential stability
Selection highlighting operates similarly: the controller reads selectedLayerId and selectedFeatureIds from the store, then applies MapLibre's setFeatureState or filter expressions to visualize the selection.
Performance and Temporal Features
The store's zundo integration enables complete history tracking without bloating memory:
- Each mutation is captured as a patch
- Undo restores previous state slices
- MapCanvas re-synchronizes automatically when store state rolls back
Camera position is excluded from undo history (marked as transient in history.ts), so users can undo data edits without losing their map orientation.
Summary
- Single source of truth: All state resides in
useAppStorefrom@geolibre/core - Controller abstraction:
MapControllerisolates MapLibre operations from React components - Unidirectional data flow: UI → Store → Controller → MapLibre
- Conditional reverse flow: Map events → Store, respecting special modes
- Temporal capabilities: Full undo/redo via
zundowrapping - Deterministic synchronization: Stable events (
moveend) trigger state updates, avoiding gesture loops
Frequently Asked Questions
What state management library does GeoLibre use?
GeoLibre uses Zustand for state management, wrapped with the zundo middleware to provide undo/redo functionality. The store is defined in packages/core/src/store.ts and consumed throughout the application via the useAppStore hook.
How does GeoLibre prevent UI components from directly manipulating the map?
UI components only interact with the Zustand store. They read state through selectors and mutate state through typed actions. The MapCanvas component in packages/map/src/MapCanvas.tsx is the sole module that subscribes to store changes and translates them into MapLibre operations via the MapController class.
Why doesn't the store update on every map movement?
GeoLibre only persists camera state on stable MapLibre events—specifically moveend and projectiontransition. This prevents excessive store updates during continuous gestures like panning or zooming, reduces re-render overhead, and ensures undo history remains meaningful.
Can users undo map navigation?
No. Camera position is treated as transient state in the history configuration. When users press undo, they revert data edits, layer changes, or selection operations without losing their current map view.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →