Skip to main content
Version: 4.3

Adding & Updating Elements

A diagram is rarely static. This chapter is about reading the graph and changing it while the app runs, the foundation for toolbars, inspectors, and anything data-driven.

These are the same workflow steps from the last chapter. Each one below has a status button that updates its own data, and the toolbar adds and resets elements. Watch the count change.

import {
  GraphProvider,
  Paper,
  HTMLBox,
  useGraph,
  useCells,
  useCellId,
  type ElementRecord,
} from '@joint/react';
import '@joint/react/styles.css';
import './example.css';

type Status = 'todo' | 'doing' | 'done';
type TaskData = { label: string; status: Status };

const NEXT: Record<Status, Status> = { todo: 'doing', doing: 'done', done: 'todo' };

const initialCells = [
  { id: 't1', type: 'element', position: { x: 100, y: 100 }, data: { label: 'New Ticket', status: 'done' } },
  { id: 't2', type: 'element', position: { x: 300, y: 100 }, data: { label: 'Classify', status: 'doing' } },
  { id: 't3', type: 'element', position: { x: 500, y: 100 }, data: { label: 'Send Reply', status: 'todo' } },
];

// A node reads its own data and writes back to the graph.
function TaskNode({ label, status }: TaskData) {
  const id = useCellId();
  const { setCellData } = useGraph<ElementRecord<TaskData>>();

  return (
    <HTMLBox className="node">
      <span className="node__label">{label}</span>
      <button
        className={`pill pill--${status}`}
        onClick={() => setCellData(id, (prev) => ({ ...prev, status: NEXT[prev.status] }))}
      >
        {status}
      </button>
    </HTMLBox>
  );
}

// A toolbar reads the whole graph and adds / clears elements.
function Toolbar() {
  const { setCell, resetCells } = useGraph();
  const count = useCells((cells) => cells.length);

  const addStep = () => {
    const id = `t${Date.now()}`;
    setCell({ id, type: 'element', position: { x: 300 + (Math.random() - 0.5) * 200, y: 150 + (Math.random() - 0.5) * 150 }, data: { label: 'New step', status: 'todo' } });
  };

  return (
    <div className="toolbar">
      <button onClick={addStep}>+ Add step</button>
      <button onClick={() => resetCells(initialCells)}>Reset</button>
      <span>{count} elements</span>
    </div>
  );
}

export default function App() {
  return (
    <GraphProvider initialCells={initialCells}>
      <div className="stage">
        <Toolbar />
        <Paper renderElement={TaskNode} className="canvas" />
      </div>
    </GraphProvider>
  );
}


Reading state

Your renderer already gets the current element's data as props (see Your First Diagram). Two hooks cover the reads that props don't: reading another element, or reading across the graph.

const count = useCells((cells) => cells.length);        // anything across the graph
const data = useCell(id, selectElementData<TaskData>); // a specific element, by id
  • useCells(selector): reads the whole graph from anywhere inside GraphProvider. Great for counts, lists, and derived summaries.
  • useCell(id, selector): reads a specific element by id from anywhere. Pass a selector to subscribe to just one slice, so the component re-renders only when that slice changes.

For the full read API (id-targeted reads, predefined selectors, collection tracking), see Accessing State.

Changing state

useGraph() returns the setters. They mutate the graph and every subscribed component updates automatically:

const { setCell, setCellData, removeCell, resetCells } = useGraph();

// Update one element's data. The updater form receives the previous data,
// so you always build the next state from the latest values.
setCellData(id, (prev) => ({ ...prev, status: 'done' }));

// Add an element: a new id creates it, an existing id updates it
setCell({ id: 't9', type: 'element', position: { x: 280, y: 80 }, data: { label: 'New step', status: 'todo' } });

// Remove, or replace the whole graph
removeCell(id);
resetCells(initialCells);

An element can update itself: it reads its own id from useCellId():

function TaskNode() {
const id = useCellId();
const { setCellData } = useGraph();
return <button onClick={() => setCellData(id, (p) => ({ ...p, status: NEXT[p.status] }))}></button>;
}

For add / update / remove patterns in depth, see Updating State.

Who owns the data? Uncontrolled vs controlled

GraphProvider takes the data in one of two ways: the same uncontrolled-vs-controlled choice React has for form inputs (if you have set value + onChange on an <input>, this will feel familiar).

Uncontrolled: initialCells (everything so far). You hand the graph a starting array and it owns the state from then on. You read and mutate it through the hooks above. This is the right default.

// The graph owns its state; initialCells just seeds it.
<GraphProvider initialCells={initialCells}></GraphProvider>

Controlled: cells + onCellsChange. You hold the cells in React state and pass them in; the graph reflects exactly what you pass. Reach for this when React must be the source of truth: to persist, undo, sync over the network, or derive the graph from other state.

// You own the state; the graph mirrors it.
const [cells, setCells] = useState(initialCells);

<GraphProvider cells={cells} onCellsChange={setCells}></GraphProvider>
initialCells (uncontrolled)cells + onCellsChange (controlled)
Owns the statethe graphyour React state
Read as inputonce, to seedevery render
When to usemost appspersistence, sync, custom undo, external state

Controlled mode in action

Here the cells live in a useState, so React is the source of truth. The panel reads positions straight from that React state, so dragging an element (which fires onCellsChangesetCells) updates it live, and the + Add element button changes the graph by updating React state, not the graph API.

import { useRef, useState } from 'react';
import { GraphProvider, Paper, HTMLBox } from '@joint/react';
import '@joint/react/styles.css';
import './example.css';

type TaskData = { label: string };

const startCells = [
  { id: 'a', type: 'element', position: { x: 140, y: 60 }, data: { label: 'New Ticket' } },
  { id: 'b', type: 'element', position: { x: 320, y: 160 }, data: { label: 'Classify' } },
];

function Node({ label }: TaskData) {
  return <HTMLBox className="node">{label}</HTMLBox>;
}

export default function App() {
  // React owns the cells. The graph mirrors this state.
  const [cells, setCells] = useState(startCells);
  const nextId = useRef(1); // monotonic id counter — never collides

  // Add an element by updating React state — the graph follows.
  const addElement = () => {
    const id = `n${nextId.current++}`;
    setCells((prev) => [
      ...prev,
      { id, type: 'element', position: { x: 120, y: 250 }, data: { label: id.toUpperCase() } },
    ]);
  };

  const elements = cells.filter((c) => c.type === 'element');

  return (
    <GraphProvider cells={cells} onCellsChange={(next) => setCells([...next])}>
      <div className="stage">
        <Paper renderElement={Node} className="canvas" />
        <aside className="panel">
          <div className="panel__title">React state ({elements.length})</div>
          <button onClick={addElement}>+ Add element</button>
          {elements.map((el) => (
            <div className="row" key={el.id}>
              <span>{el.data.label}</span>
              <code>
                {Math.round(el.position.x)}, {Math.round(el.position.y)}
              </code>
            </div>
          ))}
        </aside>
      </div>
    </GraphProvider>
  );
}


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

// The graph mirrors `cells`. Drag → onCellsChange → setCells → React re-renders → graph follows.
<GraphProvider cells={cells} onCellsChange={setCells}>
<Paper renderElement={Node} className="canvas" />
</GraphProvider>

// Mutate the graph by mutating React state:
setCells([...cells, { id, type: 'element', position, data }]);

onCellsChange is notification-only: it tells you the graph changed; call your own setter inside it (onCellsChange={setCells}). Because React holds the cells, you can save them, compare them, build undo/redo, or sync them over the network, anywhere you would use any other useState value. For Redux, Zustand, and the incremental onIncrementalCellsChange callback, see Controlled Mode.

Next, give those workflow steps real shape: icons, descriptions, and the ports that let them connect.