Global mutable state: policy and cleanup list
Issie's model code is immutable: state lives in the Elmish Model, updates go through
lens/prism composition, and there are no for loops or side effects. Module-level
let mutable is a deliberate exception, not a shortcut.
When a global mutable is allowed
Only two reasons count:
- Performance. Going through the model would cost something measurable — a cache on a hot path, or state written far more often than the model is rebuilt. Simulation caches and fast-simulation internals are the clear cases.
- It genuinely is not model state. DOM references, scroll positions read inside browser event handlers, Electron main-process state, and debug flags set once at startup have no sensible place in an Elmish model.
Everything else belongs in Model. A global mutable used as "somewhere to put this value until
the next message arrives" is the pattern to avoid: it survives project close, it is invisible to
undo, and it makes the update function's behaviour depend on history that is not in the model.
Before adding one, check the performance claim rather than assuming it. ModelHelpers.reduce
and reduceApprox enumerate fields explicitly rather than comparing whole records, so adding a
Model field costs nothing for view memoisation, and an update function that already rebuilds the
model record pays nothing for one more field. Every mutable removed from this list so far turned
out to have no performance argument at all.
Audit
Function-local let mutable inside a loop is an implementation detail and is not covered here —
this is about module-level state.
Justified: caches and hot paths
Where |
What |
|---|---|
|
|
|
|
|
|
|
|
|
|
Common/Helpers.fs has a lastKey / lastValue pair inside memoizeBy, and a last slot inside
memoizeByIdentity. Both are local to each memoised function rather than module-level, so they are
out of scope here — noted only because they look like the exception and are not.
memoizeByIdentity is what the wave selector works through: the waves of each sheet instance, the
instances inside each instance, the wave each identity now names, and which components are RAMs.
Each reads an entry per instance or per wave — tens of thousands on a design that expands — and each
was being redone on every render, so on every keystroke in a search box and every tick of a
checkbox. Identity rather than = because these arguments are rebuilt rather than mutated, so a new
object is exactly the signal that the answer is stale; comparing two Maps or two FastSimulations
structurally would cost more than recomputing.
Justified: not model state
Where |
What |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
To clean up: probably should be in Model
These look like UI state that leaked into globals. None has a stated performance reason, and each would need the same judgement applied before moving: confirm the write frequency, then move it.
-
*
UI/UpdateHelpers.fs—rightClickElement: RightClickElement.* Which element was right-clicked, set in an event handler and read when the menu action fires. Cross-message bookkeeping of exactly the kind that belongs inModel. Check first whether it is written on every mouse event or only on right-click; if the latter, there is no performance argument. -
*
UI/MemoryEditorView.fs—dynamicMem: Memory1.* Working state of the memory editor dialog.PopupDialogDataalready holds dialog state and is the obvious home. Note this one may be written per keystroke in a large memory table, so measure before moving.
Not everything in this position moves into Model. A mutable that exists because a DOM handler
cannot read the model — it must decide preventDefault synchronously, and the model is not
reachable from inside the handler — belongs in the "not model state" table above instead. That is
what KeyBindings.modelContext and SheetDisplay.physicalModifierHeld are.