Skip to content

Controllable state

Every piece of interactive state in every component — a viewport, a selection, a row order, a tree’s arrangement, panel sizes — follows the same contract. Each is a group of four props named for the state:

Prop Purpose
viewport The current value. Present → controlled: you own it.
defaultViewport The starting value when uncontrolled: the component owns it.
onViewportChange Called on every change, in every mode.
viewportStore Hand the state to an external store the component reads and writes directly.

The same four exist as selection/defaultSelection/onSelectionChange/selectionStore, rowOrder/…, panelSizes/…, and so on. Once you know one, you know them all.

The component keeps the state; you can still observe every change.

<MultipleSequenceAlignment msa={msa} onSelectionChange={(s) => console.log(s.columns)} />

You keep the state, exactly like a controlled <input>. The component only reports changes — nothing visibly changes until you pass the new value back. Use this to validate, transform, or record changes (an undo stack, say).

const [selection, setSelection] = useState({ rows: [], columns: [] });
<MultipleSequenceAlignment msa={msa} selection={selection} onSelectionChange={setSelection} />;

For state shared across components or with the rest of an app, pass a StoreController — three functions, getValue/setValue/subscribe, that any store can implement. createControllableStore makes a standalone one:

import { createControllableStore, type Viewport } from "react-bio-viz";
const viewportStore = createControllableStore<Viewport>(initialViewport);
<MultipleSequenceAlignment msa={msa} viewportStore={viewportStore} />
<MultipleSequenceAlignment msa={msa} viewportStore={viewportStore} /> {/* same window */}
viewportStore.setValue((v) => ({ ...v, x0: 100, x1: 100 + (v.x1 - v.x0) }));

Below, two alignments share one viewport store — pan or zoom either and the other follows; the button writes to the store directly:

To keep the state in an existing Zustand store, bind one slice of it with createZustandStoreController(appStore, (s) => s.msaViewport, (store, next) => …). The Jupyter widgets use the same seam — each synced traitlet is a StoreController — which is why no component contains any Jupyter-specific code.

Every component comes in two layers. SimplePhyloTree, SimpleMultipleSequenceAlignment and the other Simple* components are the bare views, fully controlled: they keep no state, so each interaction only takes effect once you pass the reported value back. Omit a value to show its default — the whole gene, the top of the tree — until the first interaction reports one:

const [viewport, setViewport] = useState<Viewport>();
<SimplePhyloTree tree={tree} viewport={viewport} onViewportChange={setViewport} />

The components without the prefix wrap them: they add the pan/zoom toolbar (and the BLAST metric selector), and hold each piece of state in one of the three modes above — uncontrolled state lives in a private Zustand store. Reach for a Simple* component to build your own navigation around a view; ViewportToolbar is exported for reuse.

Components never change the data you pass in (msa, tree). When the user renames or removes a row, the component calls onRenameRow / onRemoveRows / onRemoveColumns, and you apply the change and pass the new data back. That keeps edit history, undo and validation in your hands — see Editing an alignment.