Skip to content
D
Documentation

State and event flow

concept
5 min readUpdated

State ownership determines whether Grafloria keeps edits in its live model or returns them to your application so controlled inputs stay in sync.

The framework bindings are thin skins over one headless model: specs describe your intent, live models hold the data, and the engine owns behavior. You choose the owner separately for nodes, edges and, where supported, groups.

Choose the state owner

InputOwnerWhat happens after mounting
defaultNodes, defaultEdges, defaultGroupsThe instanceDefaults seed the collections once. Changing a default later does not reconcile the collection.
nodes, edgesYour applicationUpdated inputs reconcile into the live model; change callbacks or two-way bindings return canvas edits.
groupsYour application supplies the group collectionUpdated inputs reconcile group membership and frames. There is no corresponding group-change callback in the flow bindings.

React, Vue and Qwik expose these default and controlled inputs. For an uncontrolled Angular canvas, leave nodes and edges unbound and pass an engine through [engine]. Its canvas has no groups or defaultGroups input.

Choose defaults for a self-contained canvas. Choose controlled inputs when an inspector or other application UI needs to mirror edits. Controlled specs reconcile into existing models rather than remounting the canvas. Stable node ids keep live identity; omitting selected leaves the current selection alone.

mermaid
flowchart LR
  A["Application specs"] -->|"Controlled inputs"| B["Reconcile live models"]
  D["Defaults at mount"] --> B
  B --> C["Engine and renderer"]
  C -->|"User edits"| B
  B -->|"Change callback or model write"| A

Groups are zones with real membership, not a second list of nodes. A GroupSpec names members through children; without bounds, its frame fits those members with padding. You can also pass a live GroupModel. Removing a group through setGroups() keeps its nodes. For group-edit persistence, read the live document rather than expecting a group-change callback; see Save and restore documents.

Close the return path in your binding

These examples render two connected boxes and a selection count. Drag a box, release it, then select a box or the edge: the application mirrors the node and edge collections and displays the selected counts. React, Vue and Qwik also seed a zone through defaultGroups; the zone remains instance-owned.

Use the shared NodeSpec and EdgeSpec vocabulary in each framework. Put this file beside the component you choose:

ts
import type { NodeSpec, EdgeSpec, GroupSpec } from '@grafloria/renderer';

export const initialNodes: NodeSpec[] = [
  { id: 'a', label: 'Input', position: { x: 80, y: 100 },
    size: { width: 140, height: 70 } },
  { id: 'b', label: 'Output', position: { x: 320, y: 100 },
    size: { width: 140, height: 70 } },
];
export const initialEdges: EdgeSpec[] = [
  { id: 'ab', source: 'a', target: 'b' },
];
export const initialGroups: GroupSpec[] = [
  { id: 'pipeline', label: 'Pipeline', children: ['a', 'b'], padding: 40 },
];

React

GrafloriaFlow calls onNodesChange with live NodeModel objects and onEdgesChange with live LinkModel objects. useNodesState and useEdgesState convert those models back to specs. Their third tuple elements close the return path; their second elements are application state setters.

bash
npm install @grafloria/react @grafloria/engine @grafloria/renderer @grafloria/element react react-dom
tsx
import { useState } from 'react';
import { GrafloriaFlow, useNodesState, useEdgesState } from '@grafloria/react';
import { initialNodes, initialEdges, initialGroups } from './graph';

export default function Editor() {
  const [nodes, , onNodesChange] = useNodesState(initialNodes);
  const [edges, , onEdgesChange] = useEdgesState(initialEdges);
  const [selection, setSelection] = useState('0 nodes, 0 edges');

  return (
    <section>
      <p>Selected: {selection}</p>
      <GrafloriaFlow
        nodes={nodes} edges={edges} defaultGroups={initialGroups}
        onNodesChange={onNodesChange} onEdgesChange={onEdgesChange}
        onSelectionChange={({ nodes: pickedNodes, edges: pickedEdges }) =>
          setSelection(`${pickedNodes.length} nodes, ${pickedEdges.length} edges`)}
        style={{ height: 400 }}
      />
    </section>
  );
}
Input connects to Output inside the Pipeline zone. The selection count starts at 0 nodes, 0 edges.

Keep controlled arrays in state: React's inbound effects depend on their references. For the stale-state pitfall and the full hook contract, see React quick start and React: state and subscriptions.

Vue

GrafloriaFlow emits spec arrays through update:nodes and update:edges; v-model writes them into your refs. The selection-change event returns the selected live models, not a spec projection.

bash
npm install @grafloria/vue @grafloria/engine @grafloria/renderer @grafloria/element vue
vue
<script setup lang="ts">
import { ref } from 'vue';
import { GrafloriaFlow } from '@grafloria/vue';
import type { NodeSpec, EdgeSpec } from '@grafloria/renderer';
import { initialNodes, initialEdges, initialGroups } from './graph';

const nodes = ref<NodeSpec[]>(initialNodes);
const edges = ref<EdgeSpec[]>(initialEdges);
const selection = ref('0 nodes, 0 edges');
</script>

<template>
  <section>
    <p>Selected: {{ selection }}</p>
    <GrafloriaFlow
      v-model:nodes="nodes" v-model:edges="edges"
      :default-groups="initialGroups"
      @selection-change="selection = `${$event.nodes.length} nodes, ${$event.edges.length} edges`"
      style="height: 400px"
    />
  </section>
</template>

Vue accepts replacement arrays and in-place changes and skips reapplying its own emitted arrays. See Vue: state and composables for the reactive update paths.

Qwik

GrafloriaFlow projects live models to specs before invoking onNodesChange$ and onEdgesChange$. Store those arrays in signals. The selection callback below stores only a string, not its live-model payload.

bash
npm install @grafloria/qwik @grafloria/engine @grafloria/renderer @grafloria/element @builder.io/qwik
tsx
import { component$, useSignal } from '@builder.io/qwik';
import { GrafloriaFlow } from '@grafloria/qwik';
import type { NodeSpec, EdgeSpec } from '@grafloria/renderer';
import { initialNodes, initialEdges, initialGroups } from './graph';

export default component$(() => {
  const nodes = useSignal<NodeSpec[]>(initialNodes);
  const edges = useSignal<EdgeSpec[]>(initialEdges);
  const selection = useSignal('0 nodes, 0 edges');

  return (
    <section>
      <p>Selected: {selection.value}</p>
      <GrafloriaFlow
        nodes={nodes.value} edges={edges.value} defaultGroups={initialGroups}
        onNodesChange$={(next) => { nodes.value = next; }}
        onEdgesChange$={(next) => { edges.value = next; }}
        onSelectionChange$={(change) => {
          selection.value = `${change.nodes.length} nodes, ${change.edges.length} edges`;
        }}
        style={{ height: '400px' }}
      />
    </section>
  );
});

See Qwik: state and resumption for storing and reaching a browser-only instance.

Angular

DiagramCanvasComponent exposes nodes and edges as two-way model signals. Its input types also accept live models, so use those declared unions for your signals. SelectionChange contains the selected nodes and edges after the change.

bash
npm install @grafloria/angular @angular/common @angular/core @angular/forms @angular/platform-browser @grafloria/engine @grafloria/renderer @grafloria/element rxjs
ts
import { Component, signal } from '@angular/core';
import { DiagramCanvasComponent, type SelectionChange } from '@grafloria/angular';
import type { NodeSpec, EdgeSpec } from '@grafloria/renderer';
import type { NodeModel, LinkModel } from '@grafloria/engine';
import { initialNodes, initialEdges } from './graph';

@Component({
  selector: 'app-editor',
  standalone: true,
  imports: [DiagramCanvasComponent],
  template: `
    <p>Selected: {{ selection() }}</p>
    <grafloria-diagram-canvas
      [(nodes)]="nodes" [(edges)]="edges"
      (selectionChange)="onSelection($event)"
      style="display:block; height:400px" />
  `,
})
export class EditorComponent {
  readonly nodes = signal<readonly (NodeSpec | NodeModel)[] | undefined>(initialNodes);
  readonly edges = signal<readonly (EdgeSpec | LinkModel)[] | undefined>(initialEdges);
  readonly selection = signal('0 nodes, 0 edges');

  onSelection(change: SelectionChange): void {
    this.selection.set(`${change.nodes.length} nodes, ${change.edges.length} edges`);
  }
}
Input and Output are connected by an arrow, without a group frame. The selection count reads 0 nodes, 0 edges.

Alongside the next-array outputs, (modelChange) emits an incremental patch of added, removed and modified entities, including groups. Inbound nodes and edges writes are not echoed as patches. [skipModelUpdate]="true" suspends inbound reconciliation while outbound emissions continue. See Angular: state and tooling for persistence and component methods.

Learn the complete instance event map

DiagramInstance exposes on() for the complete DiagramEventMap. Use your binding's events first; use the instance when it does not expose the event you need. on() returns an unsubscribe function; invoke it when your subscriber unmounts. off() removes a handler by identity.

EventPayloadWhat you receive
nodes:change{ nodes: NodeModel[] }The current node collection, not a per-node delta.
edges:change{ edges: LinkModel[] }The current link collection.
selection:change{ nodes: NodeModel[]; edges: LinkModel[] }The selected nodes and edges after the change.
connect{ link: LinkModel }The added link; the model's link-add handler emits this, including programmatic additions.
reconnect{ link: LinkModel; endpoint: 'source' | 'target' }The link and endpoint after a successful reconnection gesture.
node:click{ node: NodeModel; world: { x: number; y: number } }The clicked node and diagram coordinates.
node:doubleclick{ node: NodeModel; world: { x: number; y: number } }The double-clicked node and diagram coordinates.
edge:click{ edge: LinkModel; world: { x: number; y: number } }The clicked edge and diagram coordinates.
viewport:change{ viewport: Rectangle; zoom: number }The camera rectangle and zoom.
readyvoidA one-shot notification queued on a microtask after the initial paint.

Rectangle is the viewport rectangle type. The flow callbacks also expose initialization, layout completion and collaboration readiness; these are binding hooks, not extra names in DiagramEventMap.

React uses onSelectionChange, onConnect, onNodeClick and onEdgeClick; Vue uses @selection-change, @connect, @node-click and @edge-click; Qwik uses their $ callback equivalents. Angular exposes (selectionChange) but no matching click or connect output on this canvas.

Changes are not a per-frame state stream

In the shared instance, node:changed and link:changed schedule repainting without emitting collection-change events. Adds, removals and clears emit collections. The built-in node drag emits nodes:change after a moved drag ends; it does not send a new spec array on each pointer move. Selection has its own event and does not require rewriting the document collection.

Treat React, Vue and Qwik state as a mirror that catches up at edit boundaries, not as the animation clock. An arbitrary direct model mutation can repaint without triggering their collection callbacks. For user-facing edits and history, use the command path described in Commands and history.

Angular has a distinct outbound implementation: it captures model mutations and coalesces a burst into a microtask before emitting modelChange and the bound arrays. Do not assume its emissions share the instance's drag-commit timing, or that one array event equals one undo step.

For interaction feedback while drawing a connection, see Configure editing gestures. Watch the live connection-event demo for the connection lifecycle rather than treating collection changes as pointer-move events.

Was this page helpful?