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.
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.
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
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:
useStore*) — React components onlyFine-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);
getStore*) — controllers and non-React codePoint-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;
setStore*) — controllers onlyMutate 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);
};
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>;
}
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);
}
}
}
Inside a controller, access other controllers through this.getControllersRegistry():
// Inside a controller method
this.getControllersRegistry().layerSetController.triggerResetFeatureInfo(layerPath);
this.getControllersRegistry().uiController.setCircularProgress(true);
mapViewer.controllers// In a plugin
const layerPaths = this.mapViewer.controllers.layerController.getLayerEntryLayerPaths();
this.mapViewer.controllers.timeSliderController?.checkInitTimeSliderLayerAndApplyFilters(layer, config);
| 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.
useStoreAppGeoviewHTMLElement / getStoreAppGeoviewHTMLElement root accessors, and the ban on direct document.*