Skip to content
D
Documentation

Save and restore documents

how-to
6 min readUpdated

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:

bash
npm install @grafloria/element @grafloria/engine @grafloria/renderer

React:

bash
npm install @grafloria/react @grafloria/element @grafloria/engine @grafloria/renderer react react-dom

Vue:

bash
npm install @grafloria/vue @grafloria/element @grafloria/engine @grafloria/renderer vue

Angular:

bash
npm install @grafloria/angular @grafloria/element @grafloria/engine @grafloria/renderer @angular/common @angular/core @angular/forms @angular/platform-browser rxjs

Qwik:

bash
npm 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.

ts
import 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.

ts
import { 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.

ts
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 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();
}
JavaScript: Intake connects to Review through an Order edge, with Save and Restore buttons above the canvas.

React renders the same initial graph through its flow component.

Save and Restore buttons above Intake and Review, connected by an arrow labelled Order.

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, take canvas.snapshot(), then reopen that snapshot through fromDocument() and mount it with GrafloriaDiagramComponent, 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.

ts
import { 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();
}
Save and Restore buttons above a Revenue card showing $24,000 and an Orders table containing order 1042 in Review.

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.

ts
import { 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();
}
Intake and Review connected by an Order edge, with Save and Restore buttons and the instruction to drag, save, drag again and restore.

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

OptionTypeDefaultWhat it does
WrapOptions.checksumbooleantrueAdds a checksum to the envelope; loading verifies it and throws on mismatch.
FromDocumentOptions.interactivebooleantrueReattaches kit interactions. false skips kit wiring, not all renderer editing.
FromDocumentOptions.renderWidgetFromDocumentOptions['renderWidget']Built-in widget painterReattaches your dashboard painter.
FromDocumentOptions.renderCustomNode(node: NodeModel, host: HTMLElement) => voidKit painter or registered node typeOverrides custom-node painting, including dashboard widgets.
ReplicaOptions.startClocknumberNo resume value suppliedStarts 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. toNodeSpec omits 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 through fromDocument() 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.

Was this page helpful?