geoview

Using Zustand Store

Audience: GeoView core developers

We use Zustand store for our state management. The store is split into slices (map, layer, UI, etc.) with three access patterns: selector hooks for React components, getter/setter functions for controllers, and controller methods for mutations.

Dev tools

For access to store values from within the browser you should use React Developer Tools to inspect React components. The store is active when (1) running in dev environment or (2) the local storage GEOVIEW_DEVTOOLS key is set.

Characteristics to know when using the React and Zustand store

useStore hook for variable linked to UI and can be updated from another component.

useState hook for variable linked to UI and only referenced in your component.

useEffect hook

Three Function Types per Store Slice

When a useStore* hook has no getter but non-React code needs it, create a dedicated getStore* getter in the same slice, with the hook’s return type and mapId first. Do not work around it with getStoreMapStateJson(mapId).property. Getters are also valid point-in-time reads in React. Keep matched getter/hook pairs together, getter first, in STATE GETTERS & HOOKS. The STATE GETTERS & HOOKS - OTHERS (no match between getter-hook) region contains hook-only orphans; move the hook out of OTHERS when adding its getter.

Each store state file in src/core/stores/states/ exports three types of functions:

Selector Hooks (useStore*) — React components only

Fine-grained hooks that trigger re-renders when the selected state changes.

// Pattern: useStore[SliceName][PropertyName]
export const useStoreMapZoom = (): number => useStore(useGeoViewStore(), (state) => state.mapState.zoom);

export const useStoreMapClickMarker = (): TypeClickMarker | undefined => useStore(useGeoViewStore(), (state) => state.mapState.clickMarker);

Getter Functions (getStore*) — controllers and non-React code

Point-in-time snapshots. Takes mapId as first parameter.

export const getStoreMapZoom = (mapId: string): number => getStoreMapState(mapId).zoom;

export const getStoreMapOrderedLayerInfo = (mapId: string): TypeOrderedLayerInfo[] => getStoreMapState(mapId).orderedLayerInfo;

Setter Functions (setStore*) — controllers only

Mutate state via internal actions. Takes mapId as first parameter. Never call these from React components.

export const setStoreMapZoom = (mapId: string, zoom: number): void => {
  getStoreMapState(mapId).actions.setZoom(zoom);
};

export const setStoreMapClickMarker = (mapId: string, marker: TypeClickMarker): void => {
  getStoreMapState(mapId).actions.showClickMarker(marker);
};

Store Access Patterns

React Components — read via hooks, mutate via controllers

Components read state through useStore* selector hooks and mutate through controller methods. Never call setStore* functions directly from components.

import { useStoreMapZoom, useStoreMapClickMarker } from '@/core/stores/states/map-state';
import { useMapController } from '@/core/controllers/use-controllers';

export function MyComponent(): JSX.Element {
  // Read state — re-renders when values change
  const zoom = useStoreMapZoom();
  const clickMarker = useStoreMapClickMarker();

  // Get controller for mutations
  const mapController = useMapController();

  const handleZoom = useCallback((): void => {
    mapController.zoomToExtent(extent);
  }, [mapController]);

  return <Box onClick={handleZoom}>Zoom: {zoom}</Box>;
}

Controllers — read via getters, mutate via setters

Batch operations that suppress domain handlers must explicitly synchronize the store afterward, including derived class/time/table filters. Re-enable handlers in finally; do not rely on suppressed events to refresh feature counts or “Active Filters”. For legend toggle-all (#3447), update each item’s visibility and its layer’s class filter via setStoreLayerItemVisibility after Promise.all. Add narrow batch setters when appropriate (setStoreLayerClassFilter, setStoreDataTableFilter) rather than duplicating business logic in the store.

Controllers import getStore* and setStore* functions directly. They contain the validation and business logic.

import { getStoreMapZoom, setStoreMapZoom, setStoreMapProjection } from '@/core/stores/states/map-state';

export class MapController extends AbstractMapViewerController {
  zoomToLevel(zoom: number): void {
    const mapId = this.getMapId();
    const currentZoom = getStoreMapZoom(mapId);

    // Business logic / validation
    if (zoom !== currentZoom) {
      setStoreMapZoom(mapId, zoom);
    }
  }
}

Cross-controller communication — via registry

Inside a controller, access other controllers through this.getControllersRegistry():

// Inside a controller method
this.getControllersRegistry().layerSetController.triggerResetFeatureInfo(layerPath);
this.getControllersRegistry().uiController.setCircularProgress(true);

Plugins and non-React classes — via mapViewer.controllers

// In a plugin
const layerPaths = this.mapViewer.controllers.layerController.getLayerEntryLayerPaths();
this.mapViewer.controllers.timeSliderController?.checkInitTimeSliderLayerAndApplyFilters(layer, config);

Summary Table

Context Read State Mutate State
React component useStore* hooks Controller methods via useMapController(), etc.
Inside controller getStore* getters setStore* setters
Plugin / non-React class this.mapViewer.controllers.* this.mapViewer.controllers.* methods
Test suite this.getControllersRegistry().* Controller methods

Key rule: Components never call setStore* directly — mutations always go through controller methods.

Key rule: Components never receive a MapViewer prop. Core and plugin components take a mapId (or a ControllerRegistry when rendered outside the MapViewer React context) and call controller methods. If the controller lacks a method you need, add it — don’t reach into mapViewer.map. See Controller Architecture — Best Practices.

Key rule: Store state files never log business flow. setStore* / addStore* / removeStore* adaptors are thin setters — put logger.logInfo (and logWarning / logError) in the calling controller, which owns the intent behind the change. See Logging — Where to log.

See Also