Save the live document when you need to reopen an editor without dropping its ports, link routing, groups or kit metadata. The document is the persistence API; a framework's node array is a projection for state binding, not a save format.
The examples render two connected nodes with Save and Restore buttons. Drag a node, save, drag again, then restore: the diagram returns to the saved positions. Restoration mounts the saved models rather than rebuilding them from labels and coordinates.
1. Add the shared persistence code
Run the install command for your framework in your own project.
JavaScript:
bashnpm install @grafloria/element @grafloria/engine @grafloria/renderer
React:
bashnpm install @grafloria/react @grafloria/element @grafloria/engine @grafloria/renderer react react-dom
Vue:
bashnpm install @grafloria/vue @grafloria/element @grafloria/engine @grafloria/renderer vue
Angular:
bashnpm install @grafloria/angular @grafloria/element @grafloria/engine @grafloria/renderer @angular/common @angular/core @angular/forms @angular/platform-browser rxjs
Qwik:
bashnpm install @grafloria/qwik @grafloria/element @grafloria/engine @grafloria/renderer @builder.io/qwik
Use NodeSpec and EdgeSpec for the initial data. Both nodes carry explicit ports so the example exercises more than position restoration.
tsimport type { NodeSpec, EdgeSpec } from '@grafloria/renderer';
export const nodes: NodeSpec[] = [
{
id: 'intake', label: 'Intake',
position: { x: 80, y: 100 }, size: { width: 140, height: 60 },
ports: [{ id: 'intake-out', side: 'right', type: 'output' }],
metadata: { domain: 'orders' },
},
{
id: 'review', label: 'Review',
position: { x: 360, y: 100 }, size: { width: 140, height: 60 },
ports: [{ id: 'review-in', side: 'left', type: 'input' }],
},
];
export const edges: EdgeSpec[] = [
{
id: 'order', source: 'intake', target: 'review',
sourceHandle: 'intake-out', targetHandle: 'review-in',
label: 'Order',
},
];
DiagramSerializer serializes a DiagramInstance's live model. serializeEnvelope() adds writer identity, a timestamp and, by default, an integrity checksum. fromDocument accepts the envelope, the flat serializer form, or a JSON string of either, and returns a mountable spec.
Known issue:
render(fromDocument(json), host)does not restore the document's top-level model state or saved camera: the loader passes nodes, links and groups into a newly created model, and the renderer initializes its camera separately. Until it is fixed, attach the loaded model through an engine and pass its saved viewport and zoom explicitly.
Known issue: Renderer pan and zoom do not update the model's serialized viewport. Until it is fixed, copy the instance camera into the model before serializing it.
The shared helper uses DiagramEngine only to attach the complete loaded model to the renderer. RenderOptions carries that engine and the saved camera. saveLive() returns JSON; reopen() returns the spec and options for the next mounted instance.
tsimport { DiagramEngine, DiagramSerializer } from '@grafloria/engine';
import { fromDocument, type RenderOptions } from '@grafloria/element';
import type { DiagramInstance } from '@grafloria/renderer';
export function saveLive(instance: DiagramInstance): string {
const camera = instance.viewport.getViewport();
const model = instance.getModel();
model.setViewport(
camera.x, camera.y, camera.width, camera.height,
instance.viewport.getZoom(),
);
return JSON.stringify(new DiagramSerializer().serializeEnvelope(model));
}
export function reopen(json: string) {
const spec = fromDocument(json);
const engine = new DiagramEngine();
engine.setDiagram(spec.model);
const camera = spec.model.getViewport();
const options: RenderOptions = {
engine,
viewport: { x: camera.x, y: camera.y },
zoom: camera.zoom,
};
return { spec, options };
}
2. Save and reopen a mounted graph
Restore mounts the loaded spec with its saved engine and camera instead of reseeding node and edge arrays; see Edit nodes for framework mounting and instance access.
Copy data.ts and persistence.ts beside the framework file below. Save stores JSON in browser local storage; Restore mounts a fresh instance from it. Save again after restoration to persist subsequent edits.
tsimport { render } from '@grafloria/element';
import { nodes, edges } from './data';
import { saveLive, reopen } from './persistence';
const editor = document.createElement('section');
const save = document.createElement('button');
const restore = document.createElement('button');
const host = document.createElement('div');
save.textContent = 'Save';
restore.textContent = 'Restore';
host.style.height = '400px';
editor.append(save, restore, host);
document.body.append(editor);
let instance = render({ nodes, edges }, host);
save.onclick = () => localStorage.setItem('order-document', saveLive(instance));
restore.onclick = () => {
const json = localStorage.getItem('order-document');
if (!json) return;
const loaded = reopen(json);
instance.dispose();
instance = render(loaded.spec, host, loaded.options);
};
// Call when your application removes this editor.
export function unmount() {
instance.dispose();
editor.remove();
}
tsximport { useRef, useState } from 'react';
import { GrafloriaFlow, GrafloriaDiagram } from '@grafloria/react';
import type { DiagramInstance } from '@grafloria/renderer';
import { nodes, edges } from './data';
import { saveLive, reopen } from './persistence';
export default function OrderEditor() {
const instance = useRef<DiagramInstance | null>(null);
const [loaded, setLoaded] = useState<ReturnType<typeof reopen> | null>(null);
const [generation, setGeneration] = useState(0);
function restore() {
const json = localStorage.getItem('order-document');
if (!json) return;
instance.current = null;
setLoaded(reopen(json));
setGeneration((value) => value + 1);
}
return (
<section>
<button onClick={() => {
if (instance.current) {
localStorage.setItem('order-document', saveLive(instance.current));
}
}}>Save</button>
<button onClick={restore}>Restore</button>
<div style={{ height: 400 }}>
{loaded ? (
<GrafloriaDiagram key={generation} spec={loaded.spec} options={loaded.options}
onReady={(api) => { instance.current = api; }} />
) : (
<GrafloriaFlow defaultNodes={nodes} defaultEdges={edges}
onInit={(api) => { instance.current = api; }} />
)}
</div>
</section>
);
}
vue<script setup lang="ts"> import { shallowRef, ref } from 'vue'; import { GrafloriaFlow, GrafloriaDiagram } from '@grafloria/vue'; import type { DiagramInstance } from '@grafloria/renderer'; import { nodes, edges } from './data'; import { saveLive, reopen } from './persistence'; const instance = shallowRef<DiagramInstance>(); const loaded = shallowRef<ReturnType<typeof reopen>>(); const generation = ref(0); function ready(api: DiagramInstance) { instance.value = api; } function save() { if (instance.value) { localStorage.setItem('order-document', saveLive(instance.value)); } } function restore() { const json = localStorage.getItem('order-document'); if (!json) return; instance.value = undefined; loaded.value = reopen(json); generation.value++; } </script> <template> <section> <button @click="save">Save</button> <button @click="restore">Restore</button> <div style="height:400px"> <GrafloriaDiagram v-if="loaded" :key="generation" :spec="loaded.spec" :options="loaded.options" @ready="ready" /> <GrafloriaFlow v-else :default-nodes="nodes" :default-edges="edges" @init="ready" /> </div> </section> </template>
tsimport { Component, viewChild } from '@angular/core';
import { DiagramCanvasComponent, GrafloriaDiagramComponent } from '@grafloria/angular';
import type { DiagramInstance } from '@grafloria/renderer';
import { nodes, edges } from './data';
import { saveLive, reopen } from './persistence';
@Component({
selector: 'app-order-editor',
standalone: true,
imports: [DiagramCanvasComponent, GrafloriaDiagramComponent],
template: `
<button (click)="save()">Save</button>
<button (click)="restore()">Restore</button>
@if (loaded; as document) {
<grafloria-diagram [spec]="document.spec" [options]="document.options"
(ready)="instance = $event" style="display:block;height:400px" />
} @else {
<grafloria-diagram-canvas [nodes]="nodes" [edges]="edges"
style="display:block;height:400px" />
}
`,
})
export class OrderEditorComponent {
readonly canvas = viewChild(DiagramCanvasComponent);
readonly nodes = nodes;
readonly edges = edges;
loaded?: ReturnType<typeof reopen>;
instance?: DiagramInstance;
save() {
if (this.instance) {
localStorage.setItem('order-document', saveLive(this.instance));
} else {
const snapshot = this.canvas()?.snapshot();
if (snapshot) localStorage.setItem('order-document', JSON.stringify(snapshot));
}
}
restore() {
const json = localStorage.getItem('order-document');
if (json) this.loaded = reopen(json);
}
}
tsximport { component$, noSerialize, useSignal, type NoSerialize } from '@builder.io/qwik';
import { GrafloriaFlow, GrafloriaDiagram } from '@grafloria/qwik';
import type { DiagramInstance } from '@grafloria/renderer';
import { nodes, edges } from './data';
import { saveLive, reopen } from './persistence';
export default component$(() => {
const instance = useSignal<NoSerialize<DiagramInstance>>();
const restoredJson = useSignal('');
const generation = useSignal(0);
return (
<section>
<button onClick$={() => {
if (instance.value) {
localStorage.setItem('order-document', saveLive(instance.value));
}
}}>Save</button>
<button onClick$={() => {
const json = localStorage.getItem('order-document');
if (!json) return;
instance.value = undefined;
restoredJson.value = json;
generation.value++;
}}>Restore</button>
<div style={{ height: '400px' }}>
{restoredJson.value ? (
<GrafloriaDiagram key={generation.value}
spec$={() => {
const loaded = reopen(restoredJson.value);
// Keep the engine and camera inside the browser-built spec.
return { ...loaded.spec, renderOptions: loaded.options };
}}
onReady$={(api: DiagramInstance) => { instance.value = noSerialize(api); }} />
) : (
<GrafloriaFlow defaultNodes={nodes} defaultEdges={edges}
onInit$={(api: DiagramInstance) => { instance.value = noSerialize(api); }} />
)}
</div>
</section>
);
});
React renders the same initial graph through its flow component.
Vue renders the initial graph with the same controls.
Angular's initial canvas shows the two nodes and their connection.
Qwik's resumed flow renders the same initial document.
The framework components dispose their owned instances on unmount. JavaScript explicitly disposes the old instance when replacing it and exposes an application unmount function. Qwik builds the restored spec in the browser through spec$; see Documents and kits for resumable kit state.
Known issue: Angular's intended
canvas.loadSnapshot(saved)path projects nodes and links back to specs and does not restore groups or the viewport. Until it is fixed, takecanvas.snapshot(), then reopen that snapshot throughfromDocument()and mount it withGrafloriaDiagramComponent, as above.
3. Save dashboard state
For a whole-document dashboard save, use the same saveLive() and reopen() helpers. The loader reconstructs built-in widget painters and board interaction wiring from kit metadata. The restored spec's handle is the same DashboardHandle API used by an authored dashboard.
This browser sample uses the shipped dashboard kit and its KPI and table painters. It renders a revenue card and an orders table. Save, move a widget, and restore to return to the saved cells.
tsimport { dashboard, render } from '@grafloria/element';
import { saveLive, reopen } from './persistence';
const editor = document.createElement('section');
const save = document.createElement('button');
const restore = document.createElement('button');
const host = document.createElement('div');
save.textContent = 'Save';
restore.textContent = 'Restore';
host.style.height = '400px';
editor.append(save, restore, host);
document.body.append(editor);
const spec = dashboard({
columns: 12,
widgets: [
{ id: 'revenue', kind: 'kpi', span: 4, rows: 1,
data: { label: 'Revenue', value: '$24,000' } },
{ id: 'orders', kind: 'table', span: 8, rows: 2, title: 'Orders',
data: { columns: ['Order', 'Status'], rows: [['1042', 'Review']] } },
],
});
let instance = render(spec, host);
save.onclick = () => localStorage.setItem('dashboard-document', saveLive(instance));
restore.onclick = () => {
const json = localStorage.getItem('dashboard-document');
if (!json) return;
const loaded = reopen(json);
instance.dispose();
instance = render(loaded.spec, host, loaded.options);
};
export function unmount() {
instance.dispose();
editor.remove();
}
For dashboard-only persistence, handle.toJSON() returns a DashboardSnapshot: live views, widget cells and board options. Feed it back to dashboard() rather than to fromDocument(). It reads the widest cached column layout, so saving a narrow responsive board retains the authored wide layout. Use whole-document serialization when you also need the model document rather than a board authoring snapshot.
Functions do not survive JSON: re-supply your custom widget painter through fromDocument(json, { renderWidget }), or through dashboard({ ...snapshot, renderWidget }) for a board snapshot. A document-loaded board does not restore runtime responsive configuration; it starts at its saved column count. Reattach application callbacks separately.
4. Retain collaborative history and the clock
A snapshot contains document state, not a peer's causal history. Persist the op-log tail and clock alongside it. Replica captures local mutations on the mounted model; history() returns the operations it knows in total order. On restoration, startClock resumes the clock and adopt() seeds the log and last-writer-wins stamps without applying operations whose effects are already in the snapshot. Use receive() only for operations not yet reflected in the model.
This browser sample renders the same two nodes. Drag before saving to create operations. Restore, then drag again: the new peer's clock advances beyond the saved clock. Op is the library's operation type; no application copy of that type is needed.
tsimport { Replica, type Op } from '@grafloria/engine';
import { render } from '@grafloria/element';
import { nodes, edges } from './data';
import { saveLive, reopen } from './persistence';
const editor = document.createElement('section');
const save = document.createElement('button');
const restore = document.createElement('button');
const readout = document.createElement('output');
const host = document.createElement('div');
save.textContent = 'Save';
restore.textContent = 'Restore';
readout.textContent = 'Drag a node, save, drag again, restore.';
host.style.height = '400px';
editor.append(save, restore, readout, host);
document.body.append(editor);
let instance = render({ nodes, edges }, host);
let peer = new Replica(instance.getModel(), {
actor: Array.from(crypto.getRandomValues(new Uint32Array(4)), (value) => value.toString(16)).join('-'),
});
let saved: { json: string; tail: Op[]; clock: number } | undefined;
save.onclick = () => {
const json = saveLive(instance);
saved = { json, tail: [...peer.history()], clock: peer.clock };
readout.textContent = `Saved ${saved.tail.length} operations; clock ${saved.clock}.`;
};
restore.onclick = () => {
if (!saved) return;
const loaded = reopen(saved.json);
peer.dispose();
instance.dispose();
instance = render(loaded.spec, host, loaded.options);
const startClock = saved.tail.reduce(
(maximum, op) => Math.max(maximum, op.clock), saved.clock,
);
peer = new Replica(instance.getModel(), {
actor: Array.from(crypto.getRandomValues(new Uint32Array(4)), (value) => value.toString(16)).join('-'),
startClock,
onLocalOp: (op) => {
readout.textContent = `New edit clock ${op.clock}; saved clock ${startClock}.`;
},
});
peer.adopt(saved.tail);
readout.textContent = `Restored clock ${peer.clock}. Drag to create a newer operation.`;
};
export function unmount() {
peer.dispose();
instance.dispose();
editor.remove();
}
For durable storage, persist the three fields in saved together. Keep each active peer's actor identity unique. adopt() does not advance the clock or rebuild the local undo stack, so retain clock explicitly and do not treat restoration as an undo-history restore.
Options and pitfalls
| Option | Type | Default | What it does |
|---|---|---|---|
WrapOptions.checksum | boolean | true | Adds a checksum to the envelope; loading verifies it and throws on mismatch. |
FromDocumentOptions.interactive | boolean | true | Reattaches kit interactions. false skips kit wiring, not all renderer editing. |
FromDocumentOptions.renderWidget | FromDocumentOptions['renderWidget'] | Built-in widget painter | Reattaches your dashboard painter. |
FromDocumentOptions.renderCustomNode | (node: NodeModel, host: HTMLElement) => void | Kit painter or registered node type | Overrides custom-node painting, including dashboard widgets. |
ReplicaOptions.startClock | number | No resume value supplied | Starts capture from your persisted clock. |
The option owners are WrapOptions, FromDocumentOptions and ReplicaOptions. The custom-node painter receives a NodeModel. Use the dashboard guide for custom widget rendering.
- Do not save a change-event node array or hand-project restored nodes.
toNodeSpecomits ports, general metadata and behavior. Serialize the live model or take a snapshot, then reopen the document. - When replacing plain specs with externally edited data that reuses ids, clear edges, then nodes, before setting the replacement nodes and edges. This forces fresh structure instead of patching the old objects; see Vue quick start. The document samples above mount a fresh model instead.
- JSON does not carry arbitrary behavior functions or application renderers. The loader reconstructs recognized kit behavior; supply application-specific painting and callbacks again.
- The model document does not save renderer theme or color-mode settings. These samples restore document data and the camera, not renderer configuration. Retain your application's theme and color-mode settings separately and pass them again in the restored host's renderer options.
Known issue: Saving with
saveLive(instance)and reopening throughfromDocument()does not preserve the frozen world positions of relative children whose parent was deleted: deleted-parent anchors are session-local and absent from serialization, so restoration falls back to the child's raw offset. A child frozen at (230, 140) with offset (30, 40) reopens at (30, 40). Until it is fixed, retain or restore the parent before saving if you need to preserve those positions.
Live demos and related guides
- Save and restore demo — snapshot, op-log tail and clock resumption. Source.
- Dashboard builder demo — live widget cells and board persistence.
- Documents and kits — shared documents and kit wiring.
- Synchronize editors — connect peers after restoration.
- Import diagram text and files — the text-format alternative.
Was this page helpful?