Learn / State & data flow in Vue
State & data flow in Vue
Two ownership models (default-nodes or v-model:nodes), one reconciler underneath, and a precise contract for what flows back out and when. This page pins down that contract — including the parts that surprise people — then covers the template-ref API, the composables, and save/load.
Uncontrolled: default-nodes
The uncontrolled form hands the engine an initial snapshot and steps aside:
<GrafloriaFlow :default-nodes="nodes" :default-edges="edges" />
default-nodes/default-edges are read once, at
mount — the wrapper watches only the controlled props, so later changes to these
arrays are ignored. The model owns the state from then on; reach it through
@init, a template ref, or the composables below. This is the right form
for editors where the diagram is the state and your app only needs snapshots.
Controlled: v-model:nodes
<script setup>
import { ref } from 'vue';
import { GrafloriaFlow } from '@grafloria/vue';
const nodes = ref([
{ id: 'a', position: { x: 0, y: 0 }, size: { width: 100, height: 50 }, label: 'A' },
]);
const edges = ref([]);
</script>
<template>
<div style="height: 100vh">
<GrafloriaFlow v-model:nodes="nodes" v-model:edges="edges" />
</div>
</template>
Inbound, every assignment to the ref is reconciled into the model. Outbound, the wrapper subscribes to the instance and emits specs back — but under two rules that are easy to miss and worth quoting from the source:
inst.on('nodes:change', ({ nodes: next }: { nodes: NodeModel[] }) => {
repaintSlots();
if (props.nodes !== undefined) emit('update:nodes', next.map((n) => toNodeSpec(n)));
}),
update:nodesis emitted only when thenodesprop is bound. An uncontrolled canvas never emits it — there is no half-controlled mode where you listen without binding.nodes:changefires on add and remove — membership changes, not every mutation. A drag does not stream positions throughv-model:nodesframe by frame; the specs you receive on the next membership change carry the then-current positions, because they are projected from the live model at emit time.
The wrapper's own test suite states the contract as two assertions: pushing a spec
into the ref grows the model, and model.removeNode('b') comes back as an
emitted spec array of ['a']. Prop in, spec out — that round trip is the
whole feature.
toNodeSpec()
carries id, type, position, size, selected, data, label, shape and the custom flag —
and nothing else: no ports, no metadata, no behavior. v-model:nodes keeps
your app state in sync; it is not a save format. For persistence, use
snapshot() below.The reconciler underneath
Every controlled update runs through one reconciler: add what is new, update what moved, remove what disappeared. A node whose id persists keeps its live model object — the update path patches position, size, data, label, shape and metadata in place, and never rebuilds ports (those are built once, when the id first appears).
That identity-keeping is what makes drags, undo history and selections survive a controlled update. It is also a trap when you re-import: load a second document that happens to reuse ids and the survivors keep their old ports and metadata. The recipe for a genuine fresh start is clear-then-apply:
nodes.value = []; edges.value = []; await nextTick(); // let the empty lists reconcile first nodes.value = importedNodes; edges.value = importedEdges;
(The two-step matters: Vue batches watcher runs, so assigning twice in one tick reconciles only the final value — the clear never happens.)
The template-ref surface
The component exposes a small, typed API — everything else goes through the instance:
<GrafloriaFlow ref="flow" :default-nodes="nodes" @init="onInit" />
const flow = ref();
flow.value.getInstance(); // the live DiagramInstance
flow.value.applyLayout('elk'); // or { name: 'dagre', options: {...} }
flow.value.snapshot(); // getModel().serialize() — the full document
flow.value.exportSvg(); // SVG string (sync)
await flow.value.exportPdf(); // PDF data: URL
await flow.value.exportDiagram('png', { scale: 2 });
flow.value.exportText(); // Mermaid-compatible text
flow.value.loadText(text); // ...and back in
flow.value.fitView(40); // frame the content, optional padding
@init hands you the same DiagramInstance the moment it
exists — the usual pattern is to stash it for event wiring and engine access
(instance.getEngine().undo(), interaction config, layout). Both roads
lead to the same object; the ref is just the declarative doorway.
Siblings: GrafloriaProvider and the composables
A toolbar or inspector that lives next to the canvas — not inside it — needs
the instance without prop-drilling. Wrap both in <GrafloriaProvider>
and the flow publishes itself to the nearest provider:
<template>
<GrafloriaProvider>
<InspectorPanel />
<div style="height: 80vh">
<GrafloriaFlow :default-nodes="nodes" />
</div>
</GrafloriaProvider>
</template>
<script setup>
import { useGrafloria, useSelection, useViewport, useOnSelectionChange } from '@grafloria/vue';
const grafloria = useGrafloria(); // ShallowRef<DiagramInstance | null> — null until the flow mounts
const selection = useSelection(); // reactive { nodes, edges }
const viewport = useViewport(); // reactive { zoom, x, y }
useOnSelectionChange((change) => console.log(change.nodes.map((n) => n.id)));
</script>
<template>
<aside v-if="grafloria">
{{ selection.nodes.length }} selected · zoom {{ viewport.zoom.toFixed(2) }}
</aside>
</template>
Every composable is a subscription to the headless instance — no diagram state
lives in Vue, and teardown is automatic when the component's scope disposes.
useSelection() also seeds itself from the model on attach, so a panel
mounted after a selection exists starts correct rather than empty.
Save and load
Saving is one call — snapshot() on the ref, or the serializer
directly. The result is the full document: nodes, links, ports, groups, metadata.
import { DiagramSerializer } from '@grafloria/element';
const serializer = new DiagramSerializer();
const json = JSON.stringify(serializer.serialize(instance.getModel()));
Restoring is where fidelity is decided. The
save-and-restore demo deliberately
shows the lossy road: it deserializes, then hand-projects each node back to a
spec of id/position/size/label and calls setNodes(). That round-trips
what it names and silently drops the rest — declared ports come back as the four
defaults, metadata is gone. Fine for that demo's plain boxes; wrong for anything with
structure.
The lossless door is fromDocument(): it feeds the deserialized
live models back in untouched — ports, metadata and kit wiring survive — and
it is the documented way to reload kit-built diagrams:
import { render, fromDocument } from '@grafloria/element';
render(fromDocument(JSON.parse(json)), hostElement);
<GrafloriaFlow> has no document prop — its controlled input
is specs. Re-declaring specs (clear-then-apply) is enough whenever your app owns the
source of truth anyway; reach for fromDocument() + render()
when the saved document is the source of truth and nothing may be lost.The controlled-viewer pattern
The mermaid viewer is the cleanest showcase of controlled mode: parsing happens entirely outside the canvas, and the canvas just renders whatever the refs hold.
import { importDiagramText } from '@grafloria/element';
function renderText(src) {
const r = importDiagramText(src);
if (r.unsupported) { // reports its reason instead of throwing
nodes.value = []; edges.value = [];
status.value = 'unsupported diagram type: ' + r.unsupported;
return;
}
const model = r.diagram;
nodes.value = model.getNodes().map((n) => ({
id: n.id, label: n.getMetadata('label'),
position: { x: n.position.x, y: n.position.y },
size: { width: n.size.width, height: n.size.height },
shape: n.getMetadata('shape'), style: n.style,
}));
edges.value = model.getLinks().map((l) => ({
id: l.id, source: l.sourceNodeId, target: l.targetNodeId,
}));
}
Text goes in, specs come out, the reconciler does the diffing — re-parsing edited
text updates in place because the ids persist. (And if two parses genuinely mean
different diagrams, clear-then-apply, as above.) For quick round trips the template
ref has shortcuts: exportText() / loadText().
Live: Mermaid viewer, fully controlled →
Where next
- Custom nodes in Vue — why slot repaints follow
nodes:change, and what to do about it. - Dashboards in Vue — the dashboard kit's own state story: handle, snapshot, round trip.
- Commands & undo — what "one undoable step" means in the engine.
- Mermaid & the DSL — everything
importDiagramText()understands. - The instance — the full surface behind
getInstance().