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.
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.
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.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.
<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:
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— soselectedis always current.
Common mistakes
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: '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.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
- State & data flow in React — who owns the graph, and how changes round-trip.
- Ports and validation — sides, directions, typed ports, validators.
- Dashboards in React — the same portal idiom, applied to widget boards.
- Theming — custom nodes render in light DOM, so your app's CSS cascades in.