State Management
Application state is split across seven Zustand stores. Only the entity store is wrapped with zundo undo middleware. This page documents every store's ownership boundary, what is and isn't undoable, and the R1 closure that protects FSM consistency during mid-draw undo.
1. Purpose & invariants
Goals
- Separate business parameters from UX preferences — what should and shouldn't enter the history stack.
- Undo never desyncs the FSM — the order CANCEL → temporal.undo() is load-bearing.
- Imports are a single transaction — never per-entity.
- Cross-process mirror —
licenseStoreis a read-only mirror of main-process state.
Invariants (audit points)
- Only
mapStore.entitiesenters the zundo history (partialize). - Every
mapStoremutation runs inside oneset((state) => ...)immer producer. mapStore.batchImportandreplaceImportedEntityMapcalltemporal.pause()/temporal.clear()to keep imports out of history.useActionDispatcher.ts:76-82sendsCANCELbefore undo / redo (R1).
2. Store catalogue
| Store | File | Undoable | Scope |
|---|---|---|---|
useMapStore | src/store/mapStore.ts:86 | yes (entities) | Entity store + topology + overlap reconciliation |
useUIStore | src/store/uiStore.ts:108 | no | Preferences, layer visibility, connect mode |
useSettingsStore | src/store/settingsStore.ts:107 | no | History limit, map zoom, lane half-width, etc |
useLicenseStore | src/store/licenseStore.ts:36 | no | Mirror of main-process license state |
useTaskProgressStore | src/store/taskProgressStore.ts:28 | no | Single active-task progress |
useProjDialogStore | src/store/projDialogStore.ts:25 | no | PROJ.4 picker promise gate |
useApolloMapStore | src/store/apolloMapStore.ts:56 | no | Imported raw Apollo metadata |
3. Module map
4. mapStore deep-dive
4.1 Public surface
// mapStore.ts:33-63
interface MapActions {
addEntity(entity: MapEntity): void;
updateEntity(id: string, entity: MapEntity): void;
removeEntity(id: string): void;
reparentEntity(childId: string, target: ParentTarget): ReparentResult;
batchImport(entities: MapEntity[]): void;
replaceImportedEntities(entities: MapEntity[]): void;
replaceImportedEntityMap(entities: Map<string, MapEntity>): void;
recomputeOverlapsAsync(): Promise<{...} | null>;
}2
3
4
5
6
7
8
9
10
11
4.2 zundo configuration
// mapStore.ts:259-263
{
partialize: (state) => ({ entities: state.entities }),
limit: readHistoryLimit(), // settingsStore (10–1000, default 100)
}2
3
4
5
partialize keeps zundo focused on entities only. Cursor moves and connect-mode toggles never enter history.
4.3 Mutation pipeline
Every mutation runs three steps inside immer((set, get) => ...):
- Write the body —
state.entities.set(id, entity) - Incremental topology reconcile — only when entity type ∈ {lane, junction} (
topologyAffectingType); callsreconcileLaneTopologyIncremental - Incremental overlap reconcile — merges all dirty IDs and calls
reconcileOverlaps({ mode: 'incremental', dirtyIds })
All three steps share one immer producer → one zundo snapshot. R1 closure unbroken.
4.4 batchImport: many steps in one transaction
// mapStore.ts:184-198
batchImport(entities) {
if (entities.length === 0) return;
set((state) => {
for (const e of entities) state.entities.set(e.id, e);
const { changes: topoChanges } = reconcileLaneTopology(state.entities);
for (const [cid, c] of topoChanges) state.entities.set(cid, c);
const patch = reconcileOverlaps(state.entities, { mode: 'full' });
for (const oid of patch.removedOverlapIds) state.entities.delete(oid);
for (const [oid, e] of patch.changes) state.entities.set(oid, e);
});
}2
3
4
5
6
7
8
9
10
11
12
~450 ms for 50 000 entities
Full reconcile is constrained by bench-budgets.json. For very large maps prefer recomputeOverlapsAsync() (worker path).
4.5 replaceImportedEntityMap: pause history
// mapStore.ts:206-216
replaceImportedEntityMap(entities) {
const temporal = useMapStore.temporal.getState();
temporal.pause();
try {
set({ entities });
temporal.clear(); // wipe history stack
} finally {
temporal.resume();
}
resetSharedSpatialIndex();
}2
3
4
5
6
7
8
9
10
11
12
Importing a fresh map clears history — undo must not cross map boundaries.
4.6 removeEntity cascade
removeEntity queries spatial neighbours before the delete using getSharedSpatialIndex().queryBBox(bbox) (mapStore.ts:147-158), then applies cleanups from cascadeDeleteRefsFull. This covers the case where a geometric neighbour lane does not hold an overlapIds reference but still needs overlap re-evaluation.
5. R1 closure: CANCEL before undo
Wrong order
temporal.undo() first, then CANCEL: the FSM is still in drawPolyline with N points; the next CONFIRM creates an entity from stale drawPoints, desynced from the rolled-back store. Regression test: src/hooks/__tests__/undoCancel.test.ts.
6. uiStore details
// uiStore.ts:31-58
interface UIState {
appMode: AppMode; // 'drawing' | 'scene'
gridEnabled: boolean;
snapEnabled: boolean;
layerStates: Record<string, LayerState>;
cursorLngLat: [number, number] | null;
currentZoom: number;
sidebarVisible: boolean;
currentSnapTarget: SnapTarget | null;
connectMode: { active: boolean; firstLaneId: string | null };
}2
3
4
5
6
7
8
9
10
11
12
setSnapTarget debouncing
uiStore.ts:171-187 compares prev vs target explicitly — when the snap target hasn't changed, the store is not updated. Otherwise the overlay layer re-renders on every mouse move.
7. settingsStore: localStorage persistence
// settingsStore.ts:107-148Each setter calls persist(KEY, value) synchronously. Initial values come from read*() helpers that clamp localStorage values into legal ranges.
One-shot history limit read
mapStore's zundo limit reads readHistoryLimit() once at store creation (mapStore.ts:261). Changing historyLimit later does not retroactively resize the existing temporal instance — restart or re-hydrate.
8. licenseStore: cross-process mirror
// licenseStore.ts:39-44
async hydrate() {
const next = await licenseBridge.getState();
set({ state: next, initialized: true });
}2
3
4
5
useLicenseSync() (called in WorkspaceLayout.tsx:42) subscribes to licenseBridge.onChange. Whenever the main process emits license-state-changed, the renderer store refreshes. The canEdit selector is consumed by lib/editable-guard.ts to gate mutations.
9. Smaller stores
9.1 taskProgressStore
Single active-task slot. visibleAfterMs defaults to 1000 ms — shorter tasks never show the overlay, preventing flicker.
9.2 projDialogStore
Promise gate: request() returns a pending Promise; the dialog calls resolve() on submit. A second request() while one is in flight rejects the previous one with null, preventing stacked dialogs.
9.3 apolloMapStore
// apolloMapStore.ts:33-43
header: ApolloMapHeader | null;
bounds: ApolloMapBounds | null;
info: ApolloMapImportInfo | null;
lastError: string | null;2
3
4
5
The import path keeps the entire decoded tree inside apolloIO.worker; the main thread stores only lightweight metadata, a header copy, and bounds.
10. Interaction with FSM and workers
11. Common pitfalls
Mutating entities outside the immer producer
zundo observes set calls. Mutations performed via direct useMapStore.setState({ entities: ... }) skip history.
Adding non-business fields to partialize
Anything in partialize gets snapshotted on every mutation. Adding cursorLngLat (60 Hz updates) blows past the history limit in seconds.
Holding entity references in uiStore
uiStore should keep only IDs or scalars. Storing entity references confuses selector equality checks, leaving "the selected entity points at a stale post-undo reference" bugs behind.
Calling temporal.undo() during render
useActionDispatcher calls it from event callbacks. Calling it during render triggers recursive setState.
12. Source map
src/store/mapStore.ts:86-263— full storesrc/store/mapStore.ts:91-111—addEntitysrc/store/mapStore.ts:113-134—updateEntitysrc/store/mapStore.ts:136-182—removeEntitycascadesrc/store/mapStore.ts:184-198—batchImportsrc/store/mapStore.ts:200-216— replace importsrc/store/mapStore.ts:236-257—recomputeOverlapsAsyncsrc/store/uiStore.ts:108-202— entire UI storesrc/store/settingsStore.ts:1-148src/store/licenseStore.ts:36-54src/store/taskProgressStore.ts:28-65src/store/projDialogStore.ts:25-43src/store/apolloMapStore.ts:56-86src/hooks/useActionDispatcher.ts:76-82— R1 CANCEL
13. Selector patterns and re-render hygiene
// Recommended: single field
const entityCount = useMapStore((s) => s.entities.size);
// Recommended: composite selector + memoise (zustand built-in shallow)
const { ids, count } = useMapStore(
useShallow((s) => ({ ids: [...s.entities.keys()], count: s.entities.size })),
);2
3
4
5
6
7
Don't return the Map reference itself
useMapStore(s => s.entities) re-renders on every mutation, because immer wraps Map with a new proxy. Components should select specific fields or an array of IDs.
14. zundo actor API in the dispatcher
// useActionDispatcher.ts (excerpt)
const temporal = useMapStore.temporal.getState();
// Undo:
actorRef.send({ type: 'CANCEL' }); // R1 closure
temporal.undo();
// Redo:
actorRef.send({ type: 'CANCEL' });
temporal.redo();2
3
4
5
6
7
8
useMapStore.temporal is a vanilla Zustand store injected by zundo; callable outside React, perfectly suited for the global keyboard handler hooked from a useEffect.
15. Multi-store choreography: Connect Lanes
- The user presses
C; the dispatcher callsuseUIStore.toggleConnectMode(). connectMode.active = true— the map waits for the first lane click.- First click:
uiStore.setConnectFirstLane(id). - Second click:
mapStore.connectLanes(firstId, secondId)(mutateslane.predecessor/successor), thenuiStore.exitConnectMode(). - The full flow touches
uiStoretwice andmapStoreonce. zundo history sees only the mapStore call.
16. Unit test pattern
// store/__tests__/mapStore.test.ts
import { useMapStore } from '@/store/mapStore';
beforeEach(() => {
useMapStore.setState({ entities: new Map() });
useMapStore.temporal.getState().clear();
});
test('addEntity creates one history entry', () => {
useMapStore.getState().addEntity(makeFakeLane('lane_1'));
expect(useMapStore.temporal.getState().pastStates.length).toBe(1);
});2
3
4
5
6
7
8
9
10
11
12
Test isolation
Reset state in test-local beforeEach. Do not move it to a global beforeEach — that resets unrelated stores too.
17. SettingsStore persistence policy
| Field | localStorage key | Range | Default |
|---|---|---|---|
| historyLimit | apollo-map-studio:historyLimit | 10–1000 | 100 |
| mapCenterLng | apollo-map-studio:mapCenterLng | -180–180 | MAP_DEFAULT_CENTER[0] |
| mapCenterLat | apollo-map-studio:mapCenterLat | -90–90 | MAP_DEFAULT_CENTER[1] |
| mapZoom | apollo-map-studio:mapZoom | 1–22 | MAP_DEFAULT_ZOOM |
| laneHalfWidth | apollo-map-studio:laneHalfWidth | 0.5–10 m | DEFAULT_LANE_HALF_WIDTH |
| laneArrowSpacing | apollo-map-studio:laneArrowSpacing | 40–500 px | LANE_ARROW_SYMBOL_SPACING |
readNum(key, fallback, min, max) (settingsStore.ts:35-46) is the unified read + clamp + fallback helper, eliminating the localStorage / Number / clamp boilerplate per field.
18. See also
- Architecture Overview
- Action Registry — undo / redo entry points
- FSM Design — CANCEL semantics
- entityOps Module — cascadeDeleteRefs / reparent
- Anti-Corruption Layer