Skip to main content
Version: 4.3

Controlled Mode

You already know this pattern from a React <input>:

  • <input defaultValue={x} /> is uncontrolled: React sets the value once, then the DOM owns it.
  • <input value={x} onChange={…} /> is controlled: your state owns the value, and every change flows through you.

GraphProvider is the same idea, scaled from one field to a whole graph. The only question is who owns the cells: JointJS, or your React state.

React <input>GraphProviderWho owns the cells
defaultValue={x}initialCells={cells}JointJS (uncontrolled)
value={x} + onChangecells={cells} + onCellsChangeyour React state (controlled)

Persistence is a separate question: both modes emit change events, so you can sync to a database without giving up JointJS ownership. The hooks from Accessing State and Updating State work the same either way. Mode changes who owns the cells, not how you read or write them inside the tree.

Controlled mode, live

Below, React state is the database. Drag a node, cycle a status, or add a task: every edit flows back through onCellsChange into React state, the panel re-renders from that state, and onIncrementalCellsChange reports the exact write.

The panel doesn't read graph state. It renders the same cells array you handed to GraphProvider, reaching for the isLink helper from useGraph() only to tell links from elements. The graph and your state stay in lockstep because the data lives in one place.

Uncontrolled

The default. Seed the graph once with initialCells; after mount, JointJS owns every change. Later edits to that array are ignored.

<GraphProvider initialCells={initialCells}>
<Paper renderElement={TaskNode} />
</GraphProvider>

Controlled

Pass cells to make React the source of truth, and onCellsChange to mirror user edits back into it:

const [cells, setCells] = useState(initialCells);

<GraphProvider cells={cells} onCellsChange={setCells}>
<Paper renderElement={TaskNode} />
</GraphProvider>

Two things keep this loop honest:

  • onCellsChange is notification-only: it hands you the full next array and writes nothing back on its own. You call the setter, and passing setCells directly, as above, is the whole pattern.
  • GraphProvider flags React-driven syncs internally, so a change that starts in React state does not bounce back through onCellsChange and loop forever.
Read-only from data

Pass cells without onCellsChange and the graph snaps back to your array after every interaction. You get a fully controlled, read-only view driven entirely by data.

Syncing to a database

This is the part most editors need.

You don't have to be controlled to persist

onCellsChange and onIncrementalCellsChange fire in both modes. Stay uncontrolled, let JointJS own the interaction, and still stream every change to your backend.

Both callbacks report changes that originate in the graph: user interactions and useGraph() writes. Cell arrays you pass in through the cells prop are not echoed back, since React already has that data.

The two callbacks give you two shapes of write.

The full snapshot comes from onCellsChange: you get the entire cells array after each change and save the whole document, which suits a PUT /graph endpoint or a JSON column:

<GraphProvider
initialCells={initialCells}
onCellsChange={(cells) => fetch('/api/graph', { method: 'PUT', body: JSON.stringify(cells) })}
>
<Paper renderElement={TaskNode} />
</GraphProvider>

The granular delta comes from onIncrementalCellsChange: you get just what changed since the last commit, which maps well onto row-level CRUD or a reducer:

interface IncrementalCellsChange {
added: Map<CellId, CellRecord>; // cells created
changed: Map<CellId, CellRecord>; // cells whose attributes changed
removed: Set<CellId>; // ids removed (incl. a node's links)
}

The delta maps one-to-one onto database operations:

function persist({ added, changed, removed }: IncrementalCellsChange) {
for (const [id, cell] of added) api.insert(id, cell);
for (const [id, cell] of changed) api.update(id, cell);
for (const id of removed) api.delete(id);
}

<GraphProvider initialCells={initialCells} onIncrementalCellsChange={persist}>
<Paper renderElement={TaskNode} />
</GraphProvider>
CallbackYou receiveBest for
onCellsChangefull cells arraywhole-document saves, autosave
onIncrementalCellsChange{ added, changed, removed } deltarow-level CRUD, event logs, undo/redo

They aren't exclusive. Pass both to keep a snapshot and stream deltas at the same time, which is exactly what the live demo does.

Debounce drag-heavy writes

Dragging commits many changed events per second. Debounce your snapshot save, or coalesce deltas, before hitting the network so you don't write on every frame.

A real store owns the cells the same way, through cells and onCellsChange. See External State Management for copy-paste Zustand, Redux, and Jotai examples.

What still works

Controlled mode swaps ownership, not the toolkit. Inside the same GraphProvider you keep using useCells / useCell to read and useGraph() to change the graph. The only difference: the array you hand back to GraphProvider is now the long-lived source of truth.

Quick reference

  • initialCells seeds the graph once, at mount (uncontrolled). Later changes are ignored.
  • cells is the controlled cells array. The graph re-syncs whenever the reference changes; the same reference on re-render is a no-op.
  • onCellsChange(cells) fires after every graph-originated commit (interactions, useGraph() writes) with the full array. It only notifies; call your setter inside it. Works in every mode.
  • onIncrementalCellsChange(delta) fires after every graph-originated commit with { added, changed, removed }. Works in every mode; arrays you pass in via cells are not re-reported.