Ranger architecture

3 — The store, and who owns which state

Written: 2026-08-15 · Established by: reading src/store/index.ts, src/store/types.ts and the six slice files at commit 93a9a67.

There is one Zustand store, composed from six slices, and it is the only place two subsystems ever meet. The viewer does not talk to the sidebar; both read the store. That is why the diagrams either side of this one — the load path and the write path — both pass through it.

The diagram draws only what each slice talks to outside the store. Which components read which slice is a fifteen-edge mesh that says less than the table below it, so it is a table.

useStore

file-slice

view-slice

geometry-slice

stock-pick-slice

simulation-slice

playback-slice

localStorage

IndexedDB

step-import worker

tri-dexel pool

Slice Owns Read by Reaches outside
file-slice file, fileName, fileLoadId, history, historyRevision, densityFinding everything localStorage — density limits
view-slice selectedOpId, hiddenOpIds, soloOpId, reveal, frameRequest, findRequest, pointerTool, sidebarTab viewer, editor, panels localStorage — viewer settings, export options, nav bindings
geometry-slice geometryContext, importStatus, importError viewer, Geometry panel IndexedDB, and the step-import worker
stock-pick-slice stockPickSession viewer
simulation-slice simulation, settings, checkpoints viewer, Simulation panel the tri-dexel worker pool
playback-slice playback or null viewer, playback bar

The arrows above leave the store's border rather than an individual slice, which is a limit of the layout rather than a claim — the Reaches outside column is the precise version.

Four rules that explain the shapes above

One file-level undo stack, and CodeMirror's own history is offADR-0011. history and historyRevision live in file-slice, and every programmatic writer goes through applyTransform. Diagram 4 is that path.

fileLoadId increments only when the user opens a file. Every edit replaces file with a freshly parsed object, so object identity cannot tell "opened a new program" from "re-parsed after an edit" — and the viewer must refit the camera only for the former.

reveal, frameRequest and findRequest are one-shot commands, not state. Each is an object carrying an incrementing id, and the scene and the editor key their effects on that object, so a request applies exactly once. Pressing "Top" twice has to work both times, which is why the id increments and the object is never reused.

CLAUDE.md's Key conventions still names this field cameraCommand, which no longer exists in src/. Issue #355 covers correcting it — deriving this diagram is what found it.

Geometry is document-scoped but never derived from file. geometry-slice is deliberately untouched by loadFile: an imported fixture or part body has to survive both a file reload and every edit's re-parse. It is also the only slice with an asynchronous source, so src/store/index.ts exports geometryHydrated as a promise for tests to await.

What is deliberately not in the store

Derived simulation state that would be expensive to clone. simulation-slice keeps the sweep's disposable working state in one mutable container that never goes through Zustand's set (ADR-0024 rule 7).

Each slice resets its own concerns on a new file. view-slice, playback-slice and stock-pick-slice each export a resetForNewFile, rather than loadFile reaching across and clearing fields it does not own. Issue #248 is why.

Drag to pan · scroll to zoom · Esc to close