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

App.vue
<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:

libs/vue/src/lib/grafloria-flow.ts
inst.on('nodes:change', ({ nodes: next }: { nodes: NodeModel[] }) => {
  repaintSlots();
  if (props.nodes !== undefined) emit('update:nodes', next.map((n) => toNodeSpec(n)));
}),
  • update:nodes is emitted only when the nodes prop is bound. An uncontrolled canvas never emits it — there is no half-controlled mode where you listen without binding.
  • nodes:change fires on add and remove — membership changes, not every mutation. A drag does not stream positions through v-model:nodes frame 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.

What flows back is a projection, not the document. 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:

App.vue
<template>
  <GrafloriaProvider>
    <InspectorPanel />
    <div style="height: 80vh">
      <GrafloriaFlow :default-nodes="nodes" />
    </div>
  </GrafloriaProvider>
</template>
InspectorPanel.vue
<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.

Live: save & restore →

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.

mermaid-viewer.vue (trimmed)
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