How GeoLibre's Store-Driven Architecture with Zustand Works

GeoLibre uses a centralized Zustand store wrapped with zundo's temporal enhancer to manage all application state—enabling undo/redo, cross-component synchronization, and persistence through a single source of truth defined in packages/core/src/store.ts.

GeoLibre is an open-source geospatial visualization platform built by opengeos that relies on a store-driven architecture to coordinate complex UI state, map views, and layer management. Unlike component-based state solutions, the entire application state lives in a single Zustand store, allowing any component to read or mutate data through selector functions and action methods.

Core Store Setup in packages/core/src/store.ts

The foundation of GeoLibre's architecture resides in packages/core/src/store.ts, where the AppState interface defines the complete shape of the application.

Zustand Creation with Temporal Enhancer

The store is instantiated using Zustand's create<AppState>() function, immediately wrapped by zundo's temporal enhancer to provide automatic undo/redo capabilities:

// packages/core/src/store.ts (lines 43-46)
export const useAppStore = create<AppState>()(
  temporal(
    (set, get) => ({
      // ... state and actions
    }),
    {
      limit: 50,
      equality: (a, b) => sameCamera(a.mapView, b.mapView),
    }
  )
);

The temporal wrapper records snapshots of the entire state whenever set is called. The pruneHistoryBySize option keeps the history bounded to prevent memory bloat, while the equality function uses sameCamera to avoid duplicate entries when the map view hasn't meaningfully changed.

Single Source of Truth

The AppState interface captures everything: layers, map view, UI flags, preferences, and processing history. No component holds local copies of this data; every piece of UI consults this central state through the useAppStore hook exported from @geolibre/core.

State Access Patterns and Selectors

Components interact with the store through optimized selector functions to prevent unnecessary re-renders.

Shallow Selector Optimization

Most components use inline selectors with shallow comparison to extract only the slices they need:

// packages/core/src/store.ts (lines 3-4)
import { shallow } from 'zustand/shallow';

// In a component:
const layers = useAppStore((s) => s.layers);
const mapView = useAppStore((s) => s.mapView, shallow);

By importing shallow from zustand/shallow, GeoLibre ensures that components only re-render when their specific slice of state actually changes, not when unrelated parts of the store update.

Reading Complex State

The MapCanvas.tsx component demonstrates reading multiple state slices simultaneously:

// packages/map/src/MapCanvas.tsx (lines 974-988)
const mapView = useAppStore((s) => s.mapView);
const layers = useAppStore((s) => s.layers);
const selectedFeatureId = useAppStore((s) => s.selectedFeatureId);
const setMapView = useAppStore((s) => s.setMapView);

This pattern keeps components declarative and thin, with all data flowing from the central store.

Centralized Actions and Mutations

All state mutations flow through action methods defined in the store, ensuring consistent updates and maintaining the isDirty flag automatically.

Action Method Implementation

The store defines mutators like setMapView, addLayer, and setBasemapOpacity that receive the current state via closure and return new state:

// Example from store.ts structure
setMapView: (newView) => set((state) => ({
  ...state,
  mapView: newView,
  isDirty: true
})),

This centralization guarantees that every mutation updates the dirty flag consistently, letting the application track unsaved changes across the entire project.

Direct State Access for Events

For event handlers that don't need reactive updates, components can access the store imperatively:

// Adding a GeoJSON layer directly
const { addGeoJsonLayer } = useAppStore.getState();
const newLayerId = addGeoJsonLayer("My GeoJSON", myFeatureCollection);

Using useAppStore.getState() bypasses React's render cycle, making it ideal for one-off actions triggered by user interactions.

Advanced Store Features

Beyond basic state management, GeoLibre's Zustand implementation handles persistence, collaboration, and UI coordination.

Undo/Redo with Bounded History

The zundo integration automatically snapshots state before each mutation. Users can traverse history through standard undo/redo commands, while pruneHistoryBySize ensures the history stack never exceeds configured limits. This works transparently—components don't need to implement any special logic to support time-travel debugging.

Project Persistence Actions

The store provides explicit lifecycle actions for project management:

  • newProject(): Resets state to defaults while preserving undo history structure
  • loadProject(projectData): Hydrates the store from a serialized project file
  • markSaved(): Clears the isDirty flag after successful saves

These actions live alongside the state in packages/core/src/store.ts (lines 58-70), making project serialization a store-level concern rather than a UI responsibility.

Collaboration State Isolation

A frozen DEFAULT_COLLABORATION_STATE object lives within the store but is explicitly excluded from undo history and project serialization. This separation enables live collaboration features without polluting the core data model or creating undo entries for remote user actions.

Modular UI Flags

All dialog visibility and panel states reside under the ui key in AppState:

// Opening the Vector Tools dialog from any component
const setVectorToolOpen = useAppStore((s) => s.setVectorToolOpen);

function OpenVectorToolButton() {
  return <button onClick={() => setVectorToolOpen("buffer")}>Buffer</button>;
}

This modular approach makes it trivial to open panels like VectorToolsDialog.tsx or StoryMapPanel.tsx from any component without prop drilling.

Performance and Architectural Benefits

GeoLibre's store-driven architecture eliminates prop drilling and ensures state consistency across the application. By co-locating helper functions like sameCamera, clampGridDim, and fitGrid in store.ts, the codebase keeps logic close to the state it operates on, improving maintainability.

The architecture also enables powerful debugging capabilities—developers can inspect the entire application state through a single object, track changes through the temporal history, and serialize the complete project state for persistence.

Summary

  • Single Zustand store: All state lives in packages/core/src/store.ts with the AppState interface defining the complete shape.
  • Temporal undo/redo: The zundo wrapper automatically records state snapshots and provides bounded history management.
  • Optimized selectors: Components use useAppStore with shallow comparison to extract specific slices without excessive re-renders.
  • Centralized mutations: All state changes flow through action methods (setMapView, addLayer, etc.) that maintain the isDirty flag.
  • Persistence ready: Built-in actions like loadProject and newProject handle full state hydration and reset.
  • Collaboration support: Isolated state slices enable real-time features without affecting undo history or project serialization.

Frequently Asked Questions

How does GeoLibre handle undo/redo functionality?

GeoLibre uses the zundo library's temporal enhancer wrapped around the Zustand store in packages/core/src/store.ts. This automatically captures snapshots of the entire AppState before each mutation, allowing users to traverse history with standard undo/redo commands. The history is bounded by pruneHistoryBySize to prevent memory issues, and custom equality functions like sameCamera prevent redundant history entries when state hasn't meaningfully changed.

What is the difference between using useAppStore() and useAppStore.getState()?

useAppStore(selector) is the standard React hook that subscribes components to state changes and triggers re-renders when the selected slice updates. useAppStore.getState() returns the current state object imperatively without subscribing to updates, making it ideal for event handlers or one-off actions where you don't need reactive behavior—such as calling addGeoJsonLayer inside a click handler.

How does the store track unsaved changes?

Every mutation action in the store automatically sets isDirty: true when modifying project data. The markSaved() action resets this flag to false after successful saves. This centralized approach ensures that the dirty state remains consistent regardless of which component initiated the change—whether adding layers in MapCanvas.tsx or adjusting settings in VectorToolsDialog.tsx.

Why does GeoLibre use Zustand instead of React Context or Redux?

Zustand provides a lighter-weight solution with built-in support for shallow equality checks and middleware composition (like the temporal enhancer) without the boilerplate of Redux or the re-render limitations of Context. The temporal middleware for undo/redo integrates seamlessly with Zustand's functional API, while the ability to import useAppStore anywhere in the monorepo (via @geolibre/core) eliminates the provider wrapping required by Context-based solutions.

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 →