MST patterns
JBrowse uses @jbrowse/mobx-state-tree, an internal ESM fork of
MobX-State-Tree. The public API matches
upstream MST, so the upstream documentation applies.
TL;DR: Patterns for the MST idioms JBrowse relies on: driving side-effects
with autorun, composing models from mixins, types.frozen, volatile state,
and using self over this in views.
autorun inside useEffect
Drive canvas drawing or other side-effects from MST observables in a React
component. The autorun's disposer is returned straight out of the useEffect,
so it becomes the cleanup, and every observable read inside becomes a dependency
without going in the dep array:
import { useEffect, useRef } from 'react'
import { getPreparedCanvas2D } from '@jbrowse/render-core/canvas2dUtils'
import { autorun } from 'mobx'
import { observer } from 'mobx-react'
import type { LinearMafDisplayModel } from '../stateModel.ts'
/**
* Shared absolutely-positioned band canvas for the MAF conservation and
* row-identity bands. Runs `draw` inside an `autorun` so observable map
* mutations (`rpcDataMap`/`renderBlocks`) redraw without `useEffect` deps —
* `observable.map` keeps a stable outer reference. Hidden and not drawn when
* `show` is false.
*
* `canvasWidthPx`, not `lgv.width`: every one of these bands' painters is handed
* `canvasWidthPx` as its `canvasWidth` and clamps to it, and the GPU rows canvas
* this one *replaces* in the identity/source-chromosome modes is that wide too.
* Sizing the element by the view width instead left it 2px past its own
* container (`TrackRenderingContainer` insets by the track outline under
* `contain: strict`, so the browser clipped the overhang) with its rightmost 2px
* unpainted — the exact drift `canvasWidthPx`'s own docstring records MAF making
* once before.
*/
const TrackBandCanvas = observer(function TrackBandCanvas({
model,
top,
height,
show,
draw,
}: {
model: LinearMafDisplayModel
top: number
height: number
show: boolean
draw: (ctx: CanvasRenderingContext2D) => void
}) {
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const width = model.canvasWidthPx
useEffect(
() =>
autorun(() => {
const ctx = getPreparedCanvas2D(canvasRef.current, width, height)
if (ctx && show) {
draw(ctx)
}
}),
[width, height, show, draw],
)
return show ? (
<canvas
ref={canvasRef}
style={{
position: 'absolute',
top,
left: 0,
width,
height,
pointerEvents: 'none',
}}
/>
) : null
})
export default TrackBandCanvas
The draw callbacks read model.rpcDataMap from inside the autorun, so a
mutation there redraws the canvas without React re-running the effect; the deps
carry only what changes the canvas's size or identity. Sizing goes through
getPreparedCanvas2D, which handles device pixel ratio — don't set
width/height by hand.
Prefer autorun over reaction for drawing: it runs immediately and tracks
dependencies automatically. Use reaction only to separate the tracked
expression from the effect.
To read an observable inside an autorun without making it a dependency, wrap
it in untracked(). The dotplot's fetch tracks exactly one computed and reads
everything else untracked:
const fetchKey = self.currentFetchKey
// Untracked: the values behind that key. Reading them here rather than
// as deps keeps raw offsetPx/width changes from refiring the fetch,
// while the worker still sees the current axes.
// eslint-disable-next-line no-restricted-syntax -- effect input: the worker consumes the axes, fetchKey is the decision
return untracked(() => ({
fetchKey,
// the resolved tier, which is what `currentFetchKey` above carries —
// `view.lodMode` stays 'auto' while the tier flips under it
lodTier: self.lodTier,
hViewSnap: makeViewSnap(view.hview),
vViewSnap: makeViewSnap(view.vview),
regions: self.fetchRegions,
}))
That is the shape to copy for any expensive effect: fold every input that should
trigger it into one computed, track only that, and read the raw values inside
untracked. Tracking the underlying observables individually would refire the
worker fetch on every pan frame for an identical result.
untracked above is deliberate. An MST action is the same thing by accident:
actions run untracked, so moving an autorun's body into one leaves the autorun
with no dependencies at all. It fires exactly once, never again, and nothing
throws or warns.
This is easy to walk into because factoring the body out is the obvious way to
reuse it — from a menu item, or from a flush-on-teardown path that wants the
same work on demand. The fix is to duplicate the reads in the autorun and say
why in a comment; RegionTooLargeMixin's byte gate is the worked example.
The same trap wearing different clothes: self.someAction(getSnapshot(self))
tracks fine, and only because the snapshot is taken in the argument list, before
the action is entered. Move that read inside the action and the dependency
disappears with it.
Model composition
types.compose(name, ...types) layers mixins onto a base model. It takes the
name and every part up front — there is no .compose() method on a model type,
so the mixins cannot be chained on one at a time the way .views() and
.actions() are. LinearMafDisplay layers four mixins onto BaseDisplay:
.compose(
'LinearMafDisplay',
BaseDisplay,
TrackHeightMixin(),
MultiRegionDisplayMixin(),
RowHeightMixin(),
TreeSidebarMixin<MafSource>(),
ContextMenuMixin<MafContextMenuInfo>(),
types.model({
/**
* #property
*/
type: types.literal('LinearMafDisplay'),
/**
* #property
*/
configuration: ConfigurationReference(configSchema),
}),
)
.views() / .actions() / .volatile() chain onto the result of that one
call.
Mixins are factory functions returning a model type, not classes, so the same mixin can be composed at different positions in the chain without inheritance.
Keep the main model chain in one file. Splitting .views() or .actions()
across files obscures the composition order and which views depend on which.
Chaining multiple .views() blocks
self inside a .views() block is typed with everything the model had
before that block. So a later block reaches an earlier block's getters on
self, and that is the reason to split. LinearArcDisplay puts its typed
conf getter in its own block so every getter after it can read self.conf:
/**
* #getter
* the config typed off the concrete schema; `ConfigurationReference`
* erases `self.configuration` to `any`, so reads route through this to
* stay typed (same move as `BaseAdapter<CONF>`)
*/
get conf(): LinearArcDisplayConfig {
return self.configuration
},
/**
* #getter
* arcs whose feature scores below this are not drawn; 0 (the default)
* draws every arc, as does any feature carrying no score
*/
get minScore(): number {
return getConf(self, 'minScore')
},
/**
* #getter
* the score span the filter slider is laid out over, `undefined` when the
* loaded features give it nothing to filter on
*/
get scoreRange() {
return self.features && featureScoreRange(self.features)
},
}))
.views(self => ({
/**
* #getter
*/
get displayMode(): ArcDisplayMode {
return getConf(self, 'displayMode')
},
Use multiple blocks when a getter depends on another getter, making the dependency explicit through ordering.
types.frozen
Use types.frozen() for data that is:
- Large and doesn't need deep reactivity (e.g., an array of 10k feature objects)
- Stored as a plain JSON value and hydrated lazily into MST nodes on first access
// `RowSortSpec`, not a second spelling of it: the autorun that consumes
// this and `setSortRowsBy` are both typed on it, so an inline shape here
// is a copy that can only ever drift away from the one doing the
// checking.
sortRowsBy: types.maybe(types.frozen<RowSortSpec>()),
types.frozen<T>() takes the shape as a type parameter and stores a plain
value. Wrap it in types.maybe or types.optional the same as any other type —
frozen is about what MST does with the value, not about whether it is present.
Frozen values are compared by reference; MST does not track fields inside them.
For reactive access to a field inside a frozen value, copy it out into a regular
MST property or a .volatile() field.
To make a field a dependency of an autorun without using its value, void it —
for a frozen field that means the autorun fires when the whole value is
replaced, without enumerating its properties. The shared fetch skeleton (which
every fetch outside the per-region and global display families runs on, Hi-C's
file-header read among them) does it to a counter so the retry button re-runs
the fetch:
() => {
// the pure "go again" signal, read unconditionally above every gate so a
// Retry click re-runs the body even when nothing else moved
const reloadEpoch = self.reloadCounter
// Tracked in the same breath and for the same reason, but the mirror
// image: this one CLOSES the gate below, so a run that returned before
// the counter read would drop the one observable that can reopen it and
// Cancel would be a one-way door with a Retry button on it. Order, not
// just position: counter first, then this, then the gates.
const canceled = self.fetchCanceled === true
if (canceled || gate?.() === false) {
noteFetchAutorunRun?.('gated')
return false
}
// Teardown mutates observables `prepare` reads before the disposers run,
// and getContainingView on a detached node warns then throws.
const args = isAlive(self) ? prepare() : undefined
if (args === undefined) {
noteFetchAutorunRun?.('declined')
return false
}
// The freshness gate, and the reload that overrides it. The epoch is
// stamped at ISSUE where the key is stamped at commit: a fetch that fails
// leaves nothing current, so this gate is open anyway on the next run and
// consuming the retry here costs nothing — while a reload landing
// mid-flight is answered by the re-run the counter read above already
// guarantees.
if (
fetchKey !== undefined &&
isDataCurrent(heldKey(), fetchKey(args)) &&
reloadEpoch === issuedEpoch
) {
noteFetchAutorunRun?.('declined')
return false
}
issuedEpoch = reloadEpoch
noteFetchAutorunRun?.('fetched')
// `run` is called synchronously, so its prefix down to its first await
// executes in this derivation; `FetchPhases.run` promises those reads are
// untracked, and unlike the MST flow the LGV side hides behind, nothing
// here makes it so. Whatever the run needs tracked belongs in `prepare`.
// eslint-disable-next-line no-restricted-syntax -- effect input: run's prefix reads are the fetch's, prepare is the trigger list
untracked(() => {
void runFetchOnce(self, rotation.begin(), args, {
run,
commit: commitAndStamp,
setError,
onBegin,
onEnd,
})
})
// arms the debounce; the runs that bail above return false and stay on
// the leading edge, so the first real fetch is immediate while a
// zoom/pan refetch debounces
return true
},
Give every autorun a name as that one does — it is what shows up when
debugging which effect refired.
self over this in .views()
This is a typing rule, not a runtime one. self and self-via-this are
the same object at runtime, and both dispatch to a later block's override; what
differs is what TypeScript can see:
selfis typed with everything the model had before the block. It reaches earlier blocks, the properties, and the volatiles — and cannot see a sibling in its own block.thisinside the returned object literal is typed as that literal. It reaches same-block siblings and nothing else.
So prefer self.X, and reach for this.X only for a sibling defined in the
same block. LinearVariantDisplay's legend getters use both, one line apart:
/**
* #getter
*/
// True when features are colored by their most severe consequence impact.
get colorsByConsequenceImpact() {
return self.conf.color === CONSEQUENCE_IMPACT_JEXL
},
/**
* #getter
*/
// True when features are colored by their structural-variant class.
get colorsBySvType() {
return self.conf.color === SV_TYPE_COLOR_JEXL
},
/**
* #getter
*/
// Legend rows for whichever preset color key is active (impact tiers or SV
// classes), or none. SV-type shows the fixed class key; copy-number and
// unrecognized tokens aren't listed (the pure jexl has no present-set).
get colorLegendItems(): LegendItem[] {
if (this.colorsByConsequenceImpact) {
return IMPACT_TIERS.map(t => ({ color: t.color, label: t.tier }))
}
if (this.colorsBySvType) {
return PREDEFINED_SV_TYPES.map(t => ({
color: t.color,
label: t.label,
}))
}
return []
},
Note colorLegendItems' explicit : LegendItem[] return type. A getter read
through this has to be annotated — without it TypeScript has to infer the
literal's type from a member that refers to the literal, and gives up with a
circular-reference error. That annotation is the cost of a same-block this
read, and the reason splitting into another block is usually tidier.
If you need to extend a parent view in a subclass, destructure the super version
off self outside the returned object, before redefining it. Reading it
inside would find your own override and recurse. LinearMafDisplay appending to
the inherited track menu:
.views(self => {
const { trackMenuItems: superTrackMenuItems } = self
return {
/**
* #method
*/
trackMenuItems() {
return [
...superTrackMenuItems(),
...buildMafTrackMenuItems(self),
...mafLaunchMenuItems({
session: getSession(self),
model: self,
view: getContainingView(self) as LinearGenomeViewModel,
}),
]
},
}
})
Volatile state
Use .volatile(() => ({ … })) for state that should not be persisted in
snapshots — loading flags, fetched data, hover and menu state that a reload
should reset. The multi-row display's block, in full:
rpcDataMap: regionDataMap<MultiRowRegionData>('rpcDataMap'),
prefersOffset: true,
/**
* #volatile
* The feature under the mouse, or undefined when not hovering a block. Pure
* hover identity — the cursor position that places the tooltip is component
* state, so moving inside one block doesn't invalidate this.
*
* Named apart from the `hoveredFeature` getter it fills, because
* `BaseDisplay` declares that hook as a computed and MST refuses to
* instantiate a volatile over one.
*/
hoveredMultiRowFeature: undefined as MultiRowHit | undefined,
undefined as T | undefined is the idiom for a volatile whose type MST cannot
infer from its initial value.
Observable maps (.map<K, V>()) give reactive key-level tracking: an autorun
reading map.get(key) re-fires only when that key changes, not on every map
write.