XENOLITHGRAPH
Chapter 5 of 8

Events — drive your UI from the graph

Typed event bus, reactive hooks, in-editor panels. No refs, no polling, no manual wiring.

The contract

The editor publishes a typed event bus. Subscribe with editor.on('event-name', handler) — the call returns a disposer. Every event has a strict TypeScript payload ({ nodeId, widgetId, value }, etc.) so you get full autocomplete in your editor.

The bus is bridged off the command bus, so events fire on every path that changes the graph: user interaction, the public API, AND undo / redo. Your readout stays in sync with reality, not just with the last user gesture.

The events you’ll reach for first

EventFires whenPayload
widget:changeda widget’s value changed{ nodeId, widgetId, value }
selection:changedthe selected node set changed{ nodeIds: readonly NodeId[] }
node:added / :removeda node entered / left the graph{ node } / { nodeId }
edge:connected / :disconnecteda wire was added / removed{ edge } / { edgeId }
viewport:changedpan or zoom{ x, y, zoom }
graph:loadedloadJSON finished{ nodeCount, edgeCount }

There are pre-mutation events too (node:removing, edge:connecting) that accept payload.cancel() to veto. Covered in a later chapter on validation.

Try it

Look at the Live readout panel in the top-right corner.

  • Click any node. Selected updates.
  • Click empty canvas. Selected clears.
  • Edit the Message widget or drag the Volume slider. Last edit updates with the precise {nodeId}.{widgetId} → value triple.
  • Press Tab and spawn another node. Nodes increments.
  • Drag a wire. Edges increments.

Vanilla vs React — same data, different idiom

Vanilla: subscribe in mount, paint into a plain DOM panel mounted on editor.overlayRoot, return a teardown that disposes every subscription.

const offs = [
editor.on('selection:changed', ({ nodeIds }) => paint(nodeIds)),
editor.on('widget:changed', (e) => log(e)),
]
return () => offs.forEach((off) => off())

React: use the adapter’s reactive hooks for derived state (no refs, no manual subscription), useEffect(() => editor.on(...)) for one-shot events whose payload you stash in useState. Render in-editor UI with <XenolithPanel> — it portals into the editor’s overlay layer, themed by the active --xeno-* tokens.

function Readout() {
const editor = useEditor() // strict — throws outside <XenolithGraph>
const nodes = useNodes() // readonly Node[] — useSyncExternalStore
const selection = useSelection() // readonly NodeId[] — useSyncExternalStore
const [last, setLast] = useState('—')
useEffect(() => editor.on('widget:changed', (e) => setLast(format(e))), [editor])
return <XenolithPanel position="top-right"></XenolithPanel>
}

What the hooks give you

The React hooks (useNodes, useEdges, useSelection, useViewport, useGraphJSON) are built on useSyncExternalStore. They subscribe directly to the editor’s bus, return STABLE references between updates, and never trigger extra renders. Treat the graph like any other store — useMemo over derived data, useCallback for handlers, the usual.

The same pattern wires Vue (composables over editor.on), Svelte (readable stores), and Solid (signals) — every adapter’s job is to expose the editor’s event bus the way that framework expects state. Same events, same payloads, different idioms.

Next

Events are how you OBSERVE the graph. Next chapter: save and loadeditor.toJSON() / editor.loadJSON(...), autosave to localStorage on every edit, and what changes when you start versioning your graphs.