Learn / The element vs render() — choosing your door

The element vs render() — choosing your door

Both doors open onto the same DiagramInstance. The element speaks HTML — attributes in, DOM events out, dispose on disconnect. render() speaks JavaScript — spec in, instance out, every option on the table. This page is the full contract of each, so the choice is mechanical.

Same engine, two grips

<grafloria-flow>render(spec, target, options)
InputJSON attributes, or propertiesA spec object (or its JSON string), incl. kit specs
OutputBubbling DOM CustomEventsThe DiagramInstance, returned
OptionsThe attribute set belowEverything createDiagram() takes
TeardownAutomatic on disconnectapi.dispose(), yours to call
The instanceel.diagram (the escape hatch)The return value

The element's own doc states why it exists: "React Flow serves React. ngx-vflow serves Angular. Each is a wall around one framework. A custom element has no wall: Vue, Svelte, Solid, Lit, Alpine, plain HTML, a CMS block, a Jupyter/Observable cell and a static site all speak 'HTML element with attributes and events'." And render() is "the Mermaid-shaped top-level API" — one function, no build step, returning "the same DiagramInstance every wrapper binds to, so the 'tiny API' is not a lesser one — it is the whole engine, addressed in one call."

The element: attributes

AttributeAfter mountSemantics
nodes, edgeslive JSON arrays; a change reconciles into the running diagram
themelive "light" | "dark" (strings only here); swaps in place — the SVG is patched, not remounted. Unknown values fall back to light.
fit-viewlive Boolean by presence; setting it after mount re-frames the content
zoomlive Number; drives viewport.setZoom() when changed
readonlymount-time Boolean by presence; blocks every mutating gesture but still pans and zooms
pan, wheel-zoommount-time Opt-out booleans: on by default, disabled only by the literal value "false"
min-zoom, max-zoommount-time Numbers; camera clamp

The mount-time five are read once, deliberately: "they configure the event binder, which is created once. Changing them afterwards is rare enough that we do not tear the instance down."

Malformed JSON never takes the page down. A bad nodes/edges attribute logs [grafloria-flow] ignoring malformed JSON attribute: … and falls back to an empty array — the element still mounts a working (empty) canvas. There is a test that feeds it {{{ not json to keep this true.

The element: properties and methods

const el = document.querySelector('grafloria-flow');

el.nodes = bigGraph.nodes;      // the RICH path — real objects, no JSON round-trip
el.edges = bigGraph.edges;      // (what Vue/Svelte/Solid template bindings target)

el.fitView(40);                 // the one convenience method

el.diagram                      // the DiagramInstance — the escape hatch to everything:
el.diagram.getModel();          //   the data
el.diagram.getEngine();         //   commands, undo, layout, validation
el.diagram.renderNow();         //   force a synchronous paint
There is no el.render(). Attribute and property writes reconcile the model and the scheduler paints on the next frame; when you must measure the DOM immediately after a change, go through the instance: el.diagram.renderNow().
The reconciler keeps live objects for ids that persist. Setting el.nodes (or api.setNodes()) adds what is new, patches what exists and removes what disappeared — an existing id keeps its live NodeModel. That is what you want for incremental updates; it is not what you want when the new array carries freshly built models under old ids. The Mermaid-viewer demo documents the trap: "the reconciler treats a live model as 'already here' and leaves the existing object alone, so re-applying edited text would otherwise keep the STALE nodes for every id that still exists." The fix is clear-then-apply: setNodes([]), then setNodes(next).

Live: the clear-then-apply reconciler in the Mermaid viewer →

The element: all eight events

Instance events are forwarded as CustomEvents that bubble and are composed — they cross shadow boundaries, so a listener on document works (the test suite proves exactly that):

Evente.detail
grafloria-ready{ diagram } — the instance
grafloria-nodes-change{ nodes: NodeModel[] }
grafloria-edges-change{ edges: LinkModel[] }
grafloria-selection-change{ nodes, edges }
grafloria-connect{ link: LinkModel }
grafloria-node-click{ node, world: { x, y } }
grafloria-edge-click{ edge, world: { x, y } }
grafloria-viewport-change{ viewport, zoom }
el.addEventListener('grafloria-connect', (e) => save(e.detail.link));
el.addEventListener('grafloria-nodes-change', (e) => persist(e.detail.nodes));

The instance itself emits a few more (reconnect, node:doubleclick) — subscribe on el.diagram.on(…) for those. And teardown is automatic: disconnecting the element disposes the diagram — after el.remove(), el.diagram is null and the DOM is gone. Re-connecting mounts fresh.

Renaming the tag

import { Grafloria } from '@grafloria/element';
Grafloria.define('acme-flow');       // same class, your tag name

Importing the package already registers <grafloria-flow> for you; define() is idempotent and "safe to call on the server (where customElements does not exist)". The class itself is guarded the same way, so importing the bundle in Node or a Web Worker throws nothing.

render(spec, target, options)

import { render, DARK_THEME } from '@grafloria/element';

const api = render(
  { nodes: [...], edges: [...] },   // or the JSON string of it, or a kit spec
  '#canvas',                        // an element, or a CSS selector
  { theme: DARK_THEME, fitView: true }
);

The contract, point by point:

  • Target — an element or a selector; no match throws Grafloria.render: no element matched … rather than failing silently.
  • Spec — an object or its JSON string. A kit spec is a render spec: render(dashboard({…}), host) and render(erDiagram({…}), host) are the documented one-liners, and a kit's finalize(api) runs automatically so the diagram is fully wired from one call (see Kits).
  • Custom nodes — resolved with the precedence "explicit option > spec > registry": your options.renderCustomNode outranks a kit spec's own painter, which outranks registered node types.
The scope, quoted rather than paraphrased: "spec is data (an object or its JSON), not a Mermaid-style text DSL. The engine does have a DSL, but wiring it in is a separate card — render() is the embedding surface, not a parser." Mermaid text goes through api.loadText() / importDiagramText() — see Mermaid & the text format.

Options are everything createDiagram() accepts except nodes/edges. The ones you will actually reach for:

  • theme, colorMode ('system' follows the OS), tokenBridge — see Theming.
  • fitView, zoom, minZoom, maxZoom, viewport — the camera.
  • readonly, enablePan, enableZoom, zoomSensitivity, dragThreshold — the gesture binder (the element's mount-time attributes are exactly these).
  • interaction — passed through to the engine's interaction config.
  • renderer — the full renderer config "for every knob the ergonomic fields above do not name": floating connection points, parallel links, channel nudging, link hit-area width.
  • renderCustomNode / removeCustomNode — the custom-node hook pair; a painter that draws later (fetch, rAF, web font) should return its promise, which is what lets await api.export(…) wait for exactly the widgets that were not done.

And the pieces people look for on the instance: undo lives on the engine (await api.getEngine().undo() — ⌘Z is already wired), layout too (await api.getEngine().layout('elk')), and plugins attach with a plain static import — import { attachCanvasPlugins } from '@grafloria/element', then attachCanvasPlugins(api, { minimap: true, controls: true }). The full surface is in The DiagramInstance.

So: which door?

  • The element, when the page is the platform: a CMS block, a static site, a notebook cell, a framework without a first-class binding — anywhere "HTML in, events out, cleanup on removal" is the natural shape, and the JSON-attribute ceiling (plus el.diagram for everything above it) is acceptable.
  • render(), when you are writing application code: you want the instance in hand from line one, the full options surface, kit specs, and explicit control of disposal.

It is not a fork in the road. el.diagram is the instance render() returns, so code written against one door runs unchanged behind the other.

Where next