How GeoLibre’s Store-Driven Architecture with Zustand Works: Core Patterns Explained
GeoLibre manages all front-end state through a single Zustand store defined in packages/core/src/store.ts, wrapped with zundo for undo/redo, while UI components read and mutate that state via selectors and centralized action methods.
GeoLibre is an open-source geospatial project by opengeos that implements a store-driven architecture with Zustand to coordinate map data, UI panels, and user preferences. By housing the entire project state in one observable object and forbidding scattered component state, the codebase ensures that every update flows through a single, auditable location.
The Single Source of Truth in packages/core/src/store.ts
The heart of the system is the AppState interface declared in packages/core/src/store.ts. This interface captures every aspect of a project, including layers, map view parameters, UI visibility flags, preferences, and processing history. No React component holds its own copy of this data; every piece of UI consults the same store, making the architecture strictly store-driven.
The store is instantiated with useAppStore = create<AppState>()(temporal(...)) (lines 43–46). This produces a plain JavaScript object that is instantly observable anywhere it is imported, establishing the single source of truth for the entire application.
Undo and Redo via Zundo’s Temporal Wrapper
State mutations are automatically snapshotted by zundo’s temporal enhancer. Each time an action calls set to mutate the store, zundo pushes a new snapshot onto the history stack. To prevent unbounded memory growth, the store uses pruneHistoryBySize to cap history length.
Because the wrapper records the whole AppState object, users can step backward and forward through any project change without manual diffing or command objects.
Reading State with Selector Functions
Components import useAppStore from @geolibre/core and subscribe to only the slices they need. For example, packages/map/src/MapCanvas.tsx (lines 974–988) reads mapView, layers, and selectedFeatureId through inline selectors and calls setMapView to update the viewport.
To avoid unnecessary re-renders, most selectors run a shallow comparison using shallow from zustand/shallow (lines 3–4 of store.ts). This prevents React from rerendering when a selector returns a new object reference with identical contents.
import { useAppStore } from "@geolibre/core";
export function LayerList() {
const layers = useAppStore((s) => s.layers);
const mapView = useAppStore((s) => s.mapView);
const setMapView = useAppStore((s) => s.setMapView);
return (
<ul>
{layers.map((l) => (
<li key={l.id} onClick={() => setMapView({ center: l.center })}>
{l.name}
</li>
))}
</ul>
);
}
Centralized Action Methods
All mutations travel through action methods defined on the store, such as setMapView, addLayer, addGeoJsonLayer, setBasemapOpacity, and setMapGrid. Each method receives the current state via closure and returns a new state object, or mutates in place when it is safe to do so.
Centralizing actions inside packages/core/src/store.ts guarantees that side effects like the isDirty flag stay consistent. When any project data changes, the single isDirty boolean updates accordingly, giving the UI a reliable saved-state indicator.
import { useAppStore } from "@geolibre/core";
function OpenVectorToolButton() {
const setVectorToolOpen = useAppStore((s) => s.setVectorToolOpen);
return <button onClick={() => setVectorToolOpen("buffer")}>Buffer</button>;
}
For imperative calls outside of the React render loop, the store exposes its current state and actions through useAppStore.getState():
const { addGeoJsonLayer } = useAppStore.getState();
const newLayerId = addGeoJsonLayer("My GeoJSON", myFeatureCollection);
UI Flags and Panel Orchestration
Dialog and panel visibility is controlled through a dedicated ui branch inside AppState (lines 42–85). Flags for processing dialogs, story-map editors, raster tables, and vector tools all live here. This design makes it trivial to open or close panels from any component without prop drilling or event buses.
The VectorToolsDialog.tsx and StoryMapPanel.tsx components consume these flags directly, demonstrating how the store-driven architecture decouples UI state from component hierarchy.
Persistence and Project Loading
The store exposes newProject, loadProject, and markSaved (lines 58–70) to handle project-level transitions. These actions replace or patch the full state object while keeping zundo’s history intact and syncing the isDirty flag. When a project loads, the map view, layers, and preferences populate instantly because every subscriber reads from the same source.
Helper utilities such as sameCamera, clampGridDim, and fitGrid are also co-located in packages/core/src/store.ts (lines 71–94), keeping geometry logic adjacent to the state it operates on.
Collaboration Slice Isolation
GeoLibre includes a frozen DEFAULT_COLLABORATION_STATE inside the store (lines 85–99). This slice is deliberately excluded from both zundo snapshots and project serialization. Isolating collaboration state in this way enables real-time multi-user features without cluttering the undo history or the serialized project file.
Summary
- GeoLibre’s state lives in a single Zustand store at
packages/core/src/store.tsthat acts as the sole source of truth. - The
temporalwrapper from zundo records full-state snapshots for undo/redo and prunes history to bound memory usage. - Components read state through selectors and mutate it through centralized actions like
setMapViewandaddLayer. - Shallow comparison via
zustand/shallowminimizes React re-renders when selectors return equivalent data. - UI visibility flags under the
uikey let any component open or close panels without prop drilling. - Project persistence actions replace the full state while preserving undo history and the
isDirtyflag. - A frozen collaboration slice supports live editing without affecting project serialization or undo stacks.
Frequently Asked Questions
How does GeoLibre handle undo and redo?
GeoLibre wraps its Zustand store with zundo’s temporal enhancer inside packages/core/src/store.ts. Every mutation pushes a full snapshot onto an undo stack, and pruneHistoryBySize keeps that stack bounded. Users can step backward and forward through project changes because the entire AppState is recorded automatically.
Why does GeoLibre use Zustand instead of React Context?
Zustand provides a plain observable store that any component can import and subscribe to selectively. In GeoLibre, this means components read only the slices they need via selectors, avoiding the excessive re-render issues common with React Context. The architecture also centralizes all mutations in packages/core/src/store.ts, which is harder to enforce with dispersed context providers.
How do GeoLibre components avoid unnecessary re-renders?
Most components use useAppStore((s) => s.someSlice) combined with shallow from zustand/shallow, as seen in packages/core/src/store.ts (lines 3–4). This shallow comparison checks whether the selected data has actually changed at the object-property level, so React does not rerender when references change but values remain identical.
What is the purpose of the collaboration slice in the store?
The DEFAULT_COLLABORATION_STATE slice sits inside the Zustand store but is excluded from both zundo history and project serialization. This isolation lets GeoLibre support live collaboration features—such as real-time cursor sharing or session presence—without polluting the core geospatial data model or bloating undo snapshots.
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 →