JSON
A diagram on screen is only half the picture; the other half is being able to save it and load it back. useGraph() exposes two methods for exactly that: exportToJSON() serializes the current cells to a plain object, and importFromJSON() takes one back and replaces the graph contents.
In the demo below, drag the stickies and watch the JSON panel update live. Flip the toggle to see the difference between the default (minimal) export and one that includes every attribute. Reset graph calls importFromJSON() with the starting snapshot.
Exporting to JSON​
exportToJSON() returns a { cells: [...] } object, the same shape JointJS uses internally to describe a graph. Each entry is a cell with its id, type, position, and any other attributes it carries. From there it's plain JSON, ready for whatever destination you want (a backend, a sync engine, a file the user downloads):
function SaveButton() {
const { exportToJSON } = useGraph();
const handleSave = () => {
const blob = new Blob([JSON.stringify(exportToJSON(), null, 2)], {
type: 'application/json',
});
const url = URL.createObjectURL(blob);
const a = Object.assign(document.createElement('a'), {
href: url,
download: 'graph.json',
});
a.click();
URL.revokeObjectURL(url);
};
return <button onClick={handleSave}>Save</button>;
}
The shape mirrors dia.Graph.toJSON(): the same JSON travels cleanly between @joint/react and any other JointJS code, plain Node.js scripts included.
Importing from JSON​
importFromJSON(json) replaces the entire graph contents with whatever you hand it. JointJS fires a reset event, and joint-react subscriptions resync, so every <Paper>, useCell, and useCells consumer re-renders against the new state:
function LoadInput() {
const { importFromJSON } = useGraph();
const handleLoad = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
importFromJSON(JSON.parse(await file.text()));
};
return <input type="file" accept="application/json" onChange={handleLoad} />;
}
There is no merge mode: import always replaces. For partial updates, reach for setCell instead.
Defaults keep the JSON small​
Look at the first sticky in the JSON above: it has no data field. That record didn't supply any, so it inherited the model's defaults (text: 'New note', color: 'amber'), and exportToJSON() strips attributes that match StickyNote.defaults() to keep the output minimal. The second sticky overrode data, so its data block survives.
size shows up on both cells because HTMLHost measures each card to fit its content. Those numbers don't match the model's { width: 0, height: 0 } default, so the strip leaves them in. Anything you actually change away from defaults() always lands in the JSON.
Flip the toggle to With defaults and the full attribute set reappears, because includeDefaults: true keeps every attribute regardless:
exportToJSON(); // strips matches against defaults()
exportToJSON({ includeDefaults: true }); // keeps everything
This is the export-side payoff for custom models: the same defaults() that lets records stay terse also keeps your serialized graphs small. Bake shared structure into the subclass, and exports only carry what differs.
Importing custom models​
importFromJSON rehydrates cells through the cellNamespace you pass to <GraphProvider>. A cell with type: 'StickyNote' in the JSON only becomes a StickyNote instance (with its defaults() applied) if the namespace knows about that class:
<GraphProvider cellNamespace={{ StickyNote }} initialCells={initialCells}>
…
</GraphProvider>
Without the entry, the cell falls back to a generic Element, the subclass methods disappear, and any data that relied on defaults() to fill itself in stays empty. The same namespace covers both the initial render and every later importFromJSON call: set it once on <GraphProvider> and you're done.