# How GeoLibre's Store-Driven Architecture Manages State Between the UI and MapLibre

> Discover how GeoLibre's store-driven architecture seamlessly manages UI and MapLibre state using a single Zustand store for deterministic two-way synchronization.

- Repository: [Open Geospatial Solutions/GeoLibre](https://github.com/opengeos/GeoLibre)
- Tags: architecture
- Published: 2026-08-15

---

**GeoLibre uses a single Zustand store (`useAppStore`) as the source of truth, with a thin MapController in [`MapCanvas.tsx`](https://github.com/opengeos/GeoLibre/blob/main/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`](https://github.com/opengeos/GeoLibre/blob/main/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`](https://github.com/opengeos/GeoLibre/blob/main/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`](https://github.com/opengeos/GeoLibre/blob/main/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 |

```tsx
// 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:

```tsx
// 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:

```tsx
// 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`](https://github.com/opengeos/GeoLibre/blob/main/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`](https://github.com/opengeos/GeoLibre/blob/main/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`](https://github.com/opengeos/GeoLibre/blob/main/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`](https://github.com/opengeos/GeoLibre/blob/main/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.