Learn / Custom nodes in React

Custom nodes in React

A custom node is an ordinary React component, portal-mounted into a host element the engine creates and positions. You render the inside of the box; the engine owns the box — dragging, hit-testing, connections and selection keep working exactly as for built-in nodes.

The contract: NodeProps

Your component receives exactly four props — { id, data, selected, node }. data is the payload you declared on the spec, selected is live selection state, and node is the engine's NodeModel itself — the escape hatch for everything the other three don't cover.

Card.tsx
import { GrafloriaFlow } from '@grafloria/react';
import type { NodeProps } from '@grafloria/react';

type CardData = { title: string; owner: string };

function Card({ id, data, selected, node }: NodeProps<CardData>) {
  return (
    <div style={{ height: '100%', boxSizing: 'border-box', padding: '10px 14px',
                  background: '#fff', borderRadius: 12,
                  border: selected ? '2px solid #3B52D9' : '1.5px solid #94A5F0' }}>
      <div style={{ fontWeight: 700 }}>{data.title}</div>
      <div style={{ fontSize: 12, color: '#5A6478' }}>owner: {data.owner}</div>
    </div>
  );
}

const nodes = [
  { id: 'a', type: 'card', custom: true, position: { x: 80, y: 90 },
    size: { width: 230, height: 110 }, data: { title: 'Build', owner: 'CI' } },
  { id: 'b', type: 'card', custom: true, position: { x: 430, y: 90 },
    size: { width: 230, height: 110 }, data: { title: 'Deploy', owner: 'CD' } },
];
const edges = [{ id: 'e1', source: 'a', target: 'b' }];

<GrafloriaFlow defaultNodes={nodes} defaultEdges={edges}
               nodeTypes={{ card: Card as never }} />

Two registrations, and both are required: nodeTypes maps the node's type string to the component, and the node spec carries custom: true. Miss either and nothing mounts — more on why below.

The cast is deliberate. NodeTypes is a type-erased registry (Record<string, ComponentType<NodeProps<never>>>), so a component typed with its own NodeProps<CardData> needs as never at the registration site — the library's own tests register components exactly this way.

Live: custom React nodes →

Why custom: true — the two render paths

Grafloria draws a diagram twice over: an SVG scene graph for built-in nodes, edges and ports, and an HTML layer — a camera-registered sibling of the SVG — for nodes that render as framework components. custom: true on the spec is the switch: it sets metadata.useHTMLLayer on the model, the SVG renderer then skips the node's body entirely, and the instance creates an absolutely-positioned host element for it instead:

<div class="grafloria-flow">                <!-- your container -->
  <svg>…</svg>                               <!-- the scene graph -->
  <div class="grafloria-html-layer">         <!-- camera-registered sibling -->
    <div class="grafloria-node-host" data-node-id="a" style="left:80px; top:90px; …">
      …your component renders in here…
    </div>
  </div>
</div>

This is why the flag is not optional. Without it the node takes the SVG path and renders as a normal built-in node — your nodeTypes entry is simply never consulted, with no warning. The reverse failure is equally quiet by design: a custom: true node whose type has no entry in nodeTypes renders nothing rather than throwing an exception in the middle of a canvas.

The portal mechanism

When the core mounts a custom node it hands <GrafloriaFlow> the model and the host element; the wrapper renders your component into that element with createPortal. Portals keep the component inside your React tree — context, hooks and state all work, and React (not the diagram) owns its lifecycle. It is also why the binding uses createPortal and not createRoot: portals behave identically on React 17, 18 and 19.

The host mounts once. Moving the node — a drag, a layout run, a setNodes reconcile — updates the host's left/top style; it never re-creates the element or remounts your component, so component state survives every gesture. When the node is removed from the model, the core retires the host and the wrapper drops the portal, so React unmounts your component normally.

Selection-aware styling

The selected prop is not a snapshot: the wrapper subscribes each portal to the instance's selection:change (and nodes:change) events and re-renders your component when they fire, reading node.isSelected() fresh. Styling selection is therefore just an ordinary conditional — the border: selected ? … : … line in the card above is the whole technique. There is no CSS class to hunt for and no observer to wire.

Ports on custom nodes

Ports belong to the spec, not to your component — there is no <Handle> equivalent to render. Declare them exactly as on a built-in node and the engine draws them, hit-tests them, and routes edges to them; typed ports and connection validators apply unchanged:

{ id: 'sum', type: 'card', custom: true, position: { x: 430, y: 90 },
  size: { width: 230, height: 110 }, data: { title: 'Sum' },
  ports: [
    { id: 'in',  side: 'left',  type: 'input',  dataType: 'number' },
    { id: 'out', side: 'right', type: 'output', dataType: 'number' },
  ] }

And ports are not even a prerequisite for wiring: a custom node with no declared ports still accepts connections on its body — the two cards in the demo above are joined by a plain { source: 'a', target: 'b' } edge.

Live: typed ports and type-checked wires →

Sizing: the component fills the node box

The engine sizes and positions the host from the node's size and position; your component is expected to fill it. Give the spec a real size, then make the root element height: '100%' with boxSizing: 'border-box' so padding and borders stay inside the box the engine is hit-testing. If your borders visually overhang the selection outline or edges attach slightly outside the card, a missing boxSizing is almost always why.

And the canvas itself needs a sized parent. The flow fills 100% of its container — mount it in a <div style={{ height: '100vh' }}> (or any element with a real height) or you will be staring at a perfectly healthy, zero-pixel diagram.

Reaching the live NodeModel

The node prop is the live engine model, and because the portal keeps your component in the React tree, useGrafloria() works inside a node too. Mutate through the model's tracked setters, then ask for a frame:

LiveModel.tsx
import { useGrafloria } from '@grafloria/react';
import type { NodeProps } from '@grafloria/react';

function Card({ data, node }: NodeProps<CardData>) {
  const grafloria = useGrafloria();   // the DiagramInstance, from inside the node

  const grow = () => {
    node.setSize(node.size.width + 40, node.size.height);
    node.setMetadata('label', 'wider');
    grafloria?.renderNow();
  };

  return <button onClick={grow}>{data.title}</button>;
}

setSize, setMetadata, setData and friends are tracked setters — they record the change for undo and mark the node dirty. Writing to plain properties bypasses all of that; if an edit seems to vanish, check that it went through a setter.

Live: editing a live node from outside the canvas →

When your component re-renders

Three triggers, and knowing them explains every "why didn't it update":

  • Your own state and context — it is a normal component; local useState, props from context, a store subscription all re-render it as usual.
  • nodes:change — fired when nodes are added or removed and when a gesture commits. During a drag the core moves the host element directly and does not stream events, so your component does not re-render per frame — the box moves, the contents stand still. That is a feature.
  • selection:change — so selected is always current.

Common mistakes

Forgot custom: true. The single most common failure: the node renders as a default SVG rectangle and the component never mounts. The spec flag — not the nodeTypes entry — is what routes a node through the HTML layer.
Type key mismatch. type: 'card' must match the nodeTypes key exactly; a miss renders nothing, silently, by design. If a custom node is invisible, check the flag first and the key second.
Expecting per-frame renders during a drag. Position lives on the host element, not in your props — do not try to read node.position from render and animate with it. If you need mid-gesture geometry, subscribe to the instance's events through useGrafloria() instead.

If you only need a different look — a terminal shape, a document silhouette, a fill — you may not need a component at all: per-node shape styling on the spec keeps the node on the fast SVG path.

Live: custom shapes with no components →

Where next