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 position
  • setBasemapStyleUrl(url) – switch basemap styles
  • setBasemapOpacity(opacity) – adjust basemap transparency
  • selectLayer(layerId) – change active layer selection
  • addLayer(layer) – append new data layers
  • setPointerElevation(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; moveend events 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:

  1. MapCanvas detects the change via useAppStore(state => state.layers)
  2. The controller compares new and existing layers, adding/removing MapLibre sources and layers as needed
  3. packages/map/src/geojson-loader.ts generates 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 useAppStore from @geolibre/core
  • Controller abstraction: MapController isolates 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 zundo wrapping
  • 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →