# MST patterns

JBrowse uses `@jbrowse/mobx-state-tree`, an internal ESM fork of
[MobX-State-Tree](https://mobx-state-tree.js.org/). 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:

<!-- include: plugins/maf/src/LinearMafDisplay/components/TrackBandCanvas.tsx -->

```tsx
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:

<!-- include: plugins/dotplot-view/src/DotplotDisplay/afterAttach.ts#untracked -->

```ts
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.

:::warning An autorun must do its own reads — an MST action is an untracked one

`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`:

<!-- include: plugins/maf/src/LinearMafDisplay/stateModel.ts#compose -->

```ts
.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`:

<!-- include: plugins/arc/src/LinearArcDisplay/model.ts#chainedViews -->

```ts
  /**
   * #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

<!-- include: packages/tree-sidebar/src/TreeSidebarMixin.ts#frozenProp -->

```ts
// `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:

<!-- include: packages/core/src/util/installFetch.ts#voidTracking -->

```ts
() => {
  // 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:

- `self` is 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.
- `this` inside 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:

<!-- include: plugins/variants/src/LinearVariantDisplay/model.ts#sameBlockThis -->

```ts
/**
 * #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:

<!-- include: plugins/maf/src/LinearMafDisplay/stateModel.ts#superMethod -->

```ts
.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:

<!-- include: plugins/canvas/src/LinearMultiRowFeatureDisplay/model.ts#volatile -->

```ts
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.

## See also

- [](https://jbrowse.org/jb2-staging/docs/developer_guides/data_fetching)
- [](https://jbrowse.org/jb2-staging/docs/developer_guides/configuration_schema)
- [](https://jbrowse.org/jb2-staging/docs/developer_guides/creating_view)

