How Layer Synchronization Works Between Zustand and MapLibre GL JS in GeoLibre

GeoLibre manages layer synchronization by maintaining a Zustand store as the single source of truth, with a MapController reacting to state changes to reconcile MapLibre GL JS layers through a deterministic syncLayers routine.

The GeoLibre open-source project implements a store-driven architecture to keep map visualization perfectly aligned with application state. By combining Zustand for state management and MapLibre GL JS for rendering, the codebase ensures that any change to the layer list immediately reflects on the map without direct API manipulation from UI components. Understanding how this layer synchronization between Zustand and MapLibre GL JS works is essential for developers building custom geospatial applications with GeoLibre.

The Store-Driven Architecture

Zustand as the Single Source of Truth

At the core of GeoLibre's synchronization mechanism lies a global Zustand store defined in packages/core/src/store.ts. This store maintains the layers slice, which holds an array of GeoLibreLayer objects representing the complete state of the map's data layers.

When any UI operation occurs—such as adding a layer, removing a layer, or toggling visibility—the store updates directly through dedicated actions. This design ensures that useAppStore remains the only place where layer data is mutated, preventing any scattered MapLibre API calls throughout the component tree.

The Synchronization Pipeline

Reacting to Store Updates

The bridge between the Zustand store and the MapLibre map instance occurs in React components like MapCanvas through a useEffect hook. When the layers array changes in the store, the component retrieves the current state and invokes controller.waitAndSyncLayers(layers).

This method first ensures the map style is fully loaded before proceeding, preventing race conditions during initialization. Once ready, it delegates to the core synchronization logic.

The Reconciliation Algorithm

The syncLayers method in packages/map/src/map-controller.ts (lines 1072-1088) performs a full reconciliation between the Zustand state and the MapLibre map. The algorithm executes three distinct phases:

  1. Removal of obsolete layers: The controller compares the current layerIds cache against the new layer list from the store. Any MapLibre layers whose IDs no longer exist in the store are removed via removeLayerFromMap.

  2. Bottom-up insertion: The controller iterates over the desired layer order from bottom to top (using for (let i = layers.length - 1; i >= 0; i--)). For each layer, it calls syncLayer with getBeforeStyleLayerId to determine insertion points, ensuring each layer is placed below the first style layer that belongs to a higher stack position. This preserves the correct visual z-order.

  3. Cache updates: After reconciliation, the controller updates its internal layerIds and syncedLayers caches, then reapplies basemap visibility, opacity settings, and layer control states.

Translating State to MapLibre Layers

The syncLayer Helper

Individual layer translation happens in packages/map/src/layer-sync.ts (lines 84-99) through the syncLayer helper function. This utility creates the appropriate MapLibre sources and style layers for a given GeoLibreLayer object, handling special cases such as:

  • PMTiles archives with custom protocol adapters
  • Raster and vector source configurations
  • Custom adapters for specialized data formats

By centralizing this translation logic, GeoLibre ensures that the Zustand store's abstract layer definitions correctly map to MapLibre's specific source and layer requirements.

Implementation Examples

When adding a new vector layer from a UI button, components interact only with the store:

// Example: Adding a new vector layer from a UI button
import { useAppStore } from "@geolibre/core";
import { pointLayer } from "@geolibre/core"; // helper to build a GeoLibreLayer

function AddLayerButton() {
  const addLayer = useAppStore((s) => s.layers.add);
  return (
    <button onClick={() => addLayer(pointLayer("new-points"))}>
      Add Points Layer
    </button>
  );
}

The MapCanvas component handles the synchronization side effect without direct MapLibre API calls in the UI:

// Example: MapCanvas component that triggers synchronization
import { useEffect } from "react";
import { useAppStore } from "@geolibre/core";
import { MapController } from "@geolibre/map";

export function MapCanvas({ map }) {
  const layers = useAppStore((s) => s.layers);
  const controller = new MapController(map);

  useEffect(() => {
    // Ensure the style is loaded before syncing
    controller.waitAndSyncLayers(layers);
  }, [layers, controller]);
}

Inside the MapController, the syncLayers method implements the reconciliation logic:

// Inside MapController.syncLayers (simplified)
syncLayers(layers: GeoLibreLayer[]): void {
  if (!this.isStyleReady() || !this.map) return;

  const nextIds = layers.map(l => l.id);
  // Remove stale layers
  for (const id of this.layerIds) {
    if (!nextIds.includes(id)) {
      removeLayerFromMap(this.map, id, this.syncedLayers.find(l => l.id === id));
    }
  }

  // Re‑add / update layers in correct order (bottom‑up)
  for (let i = layers.length - 1; i >= 0; i--) {
    syncLayer(this.map, layers[i], this.getBeforeStyleLayerId(layers, i));
  }

  this.layerIds = nextIds;
  this.syncedLayers = layers;
  // …apply basemap settings, refresh UI controls, etc.
}

Summary

  • Zustand store (packages/core/src/store.ts) maintains the single source of truth for all layer state through the useAppStore slice.
  • MapController (packages/map/src/map-controller.ts) subscribes to store changes and executes syncLayers to reconcile differences with MapLibre GL JS.
  • Bottom-up iteration ensures correct visual z-order when inserting layers into the map style.
  • No direct MapLibre API calls occur in UI components; all mutations flow through the centralized controller, guaranteeing deterministic state management.
  • Specialized handling for formats like PMTiles occurs in packages/map/src/layer-sync.ts, translating abstract store objects into concrete MapLibre sources and layers.

Frequently Asked Questions

Why does GeoLibre iterate bottom-up when synchronizing layers?

GeoLibre iterates from layers.length - 1 down to 0 to ensure each layer is inserted below the first style layer belonging to any layer higher in the stack. This bottom-up approach in syncLayers preserves the intended visual hierarchy and z-order without requiring complex dependency tracking between layers.

What prevents synchronization from running before the map style is ready?

The waitAndSyncLayers method checks this.isStyleReady() before executing syncLayers. If the MapLibre style is not yet loaded, the controller waits for the style to become available, preventing errors that would occur from attempting to add sources or layers to an uninitialized map canvas.

How does GeoLibre handle specialized formats like PMTiles during synchronization?

The syncLayer helper in packages/map/src/layer-sync.ts contains specialized logic for detecting and handling PMTiles archives, along with raster and vector sources. It automatically registers the necessary custom protocol adapters and creates the appropriate MapLibre source configuration based on the GeoLibreLayer type definition.

Can UI components call MapLibre API methods directly to update layers?

No. GeoLibre's architecture strictly prohibits direct MapLibre API calls from UI components. All layer mutations must go through the Zustand store actions, which then trigger the MapController.syncLayers routine. This constraint ensures a single source of truth and prevents state inconsistencies between the application store and the map visualization.

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 →