A few years ago, I built a G6 + React hybrid renderer in an internal business tool. It was the React 17 era. Last week the project upgraded to React 18, and the core structure of the bridge survived — but a handful of specific things had to change.
This isn't a "look at my performance hack" post. The graph holds maybe 15 nodes at most. Performance was never the motivation.
The real motivation: I wanted G6's existence to be invisible from the business code's point of view. Whoever writes the UI for a node should just be writing a normal React component —
importAntd, use hooks, connect Redux — with zero need to open the G6 docs.This is the design, plus a post-mortem on what it looked like crossing a React major-version boundary.
What the bridge was actually for (not performance)
Our graph is a tree-shaped configuration UI — a root, plus a few layers of nested cards. Each non-leaf node is effectively a configuration card with a title, status, and a form entry. About 10–15 nodes per screen, tops.
The two obvious paths didn't work:
-
Implementing node UI in pure G6: every business change (new status, restyle, hook into Redux) drags you into the G6 dialect — registering shapes, writing
afterDraw, callinggroup.addShape, building your own popovers. G6's learning curve ends up getting re-paid on every iteration of a fast-moving layer. - Implementing the whole graph in pure React: tree layout, edge routing, zoom, positioning — you'd reimplement what G6 already does well. That's writing a graph library.
The middle path is obvious: G6 owns the graph (layout, edges, viewport); React owns the contents of each node.
But how to make the two coexist cleanly — without ending up with two event systems fighting and props pinballing between them — that's the actual problem.
First key decision: three node types, only one mounts React
// node/define.ts
export type NodeType = 'BasicNode' | 'RootNode' | 'RichNode'
| Type | Purpose | Render strategy | Mounts React? |
|---|---|---|---|
| RootNode | Tree root | Static HTML (title + icon) | No |
| BasicNode | Simple leaf node | Static HTML with Add/Delete/Panel buttons (data-anchor) |
No |
| RichNode | Rich configuration card | G6-owned wrapper + an empty inner div + React mount | Yes, into the inner div only |
// shape.tsx — the key line in afterDraw
afterDraw(dataNode) {
const { type } = dataNode
if (type !== 'RichNode') return // the one-line filter that defines the policy
;(dataNode._draw as Function)(id)
}
This is the most restrained part of the design. Mounting React on every node would mean paying for N React trees for no reason — RootNode and BasicNode don't actually need React's capabilities. Mounting React there is waste. Not mounting it is precision.
The boundary between "mounts React" and "doesn't" lives in a single if line. Cheap to write, big in effect.
RichNode's physical structure: G6 wrapper + an empty div
G6 has a feature that isn't widely advertised but is the foundation of everything that follows: group.addShape('dom', { ... }) — it lets you embed arbitrary HTML inside SVG via <foreignObject>.
A RichNode's HTML looks like this:
// shape/RichNode.tsx (simplified)
return group.addShape('dom', {
attrs: {
...RichNode.reactInfo,
height,
html: `<div class=${style.richNode} data-anchor="PANEL" data-id=${id}>
${allowAdd
? `<span class="${style.add}" data-anchor="ADD" data-id=${id}>
<img src=${btnAdd} data-anchor="ADD" data-id=${id} />
</span>`
: ''}
<div id=${id}-react-root></div>
</div>`
}
})
This HTML is G6's territory — the outer wrapper, the delete/add buttons (exposed via data-anchor), the positioning. All G6.
But this one line in the middle:
<div id=${id}-react-root></div>
This is the window left open for React — a completely empty div. Once G6 writes it into the DOM, G6 never touches it again. React mounts an independent subtree inside.
When someone writes a node's UI, they don't know they're inside a G6 node. They can import Antd, use hooks, use portals — everything works normally.
React mounting: queueMicrotask + rootRegistry
When the afterDraw hook fires, G6 has just registered the RichNode's HTML into the SVG. But the HTML inside <foreignObject> hasn't necessarily been attached to the DOM yet — calling document.getElementById right at that moment won't find it.
The fix is to wait one microtask:
// node/define.ts
_draw(node: string): void {
const Component = this.runtime.drawRuntime(this)
const elements = React.createElement(Component)
queueMicrotask(() => {
// wait for G6 to attach the HTML, then mount React
const dom = document.getElementById(`${node}-react-root`)
if (!dom) return
let root = rootRegistry.get(node)
if (!root) {
root = createRoot(dom)
rootRegistry.set(node, root)
}
root.render(elements)
})
}
The queueMicrotask here is not about synchronizing React's commit — it's about waiting for G6 to physically attach the foreignObject HTML.
rootRegistry is a Map<string, Root> keyed by nodeId. This map was added during the React 18 migration — more on that below.
Here's the full mount flow, from G6's lifecycle hook to your React component mounting:
One more thing worth noting about the update lifecycle: when a node's data changes, G6's update hook calls _destroy then _draw — the entire React subtree is unmounted and remounted. No prop-diffing, no smart re-render path. This is heavy in theory but trivially correct, and it works fine because the trees are small. We deliberately chose "blow it away and rebuild" over writing reconciliation logic that nobody would want to maintain.
Four cross-boundary communication channels (each one minimal)
| Direction | Mechanism | Key code |
|---|---|---|
| User → G6 chrome |
data-anchor top-level delegation |
A single onClick on graphContainer; reads e.target.getAttribute('data-anchor')
|
| User → inside React subtree | Normal React events |
Nothing extra needed — onClick, Antd popovers, hooks all work inside the subtree |
| React → reading graph state | Static method BridgeNode.getGraph()
|
Injected once via useEffect at the top; callable from any component |
| Graph → React |
BridgeEvent (native CustomEvent) on window
|
dispatchEvent(new BridgeEvent(data)); components do window.addEventListener('BRIDGE_SIGNAL', ...)
|
Important to clarify: data-anchor only appears on G6-owned wrappers. Inside the React subtree, React's synthetic event system is untouched — Antd's Dropdown, Popover, Modal, event bubbling, useEffect listeners all work. data-anchor doesn't replace anything; it's just an opt-in cross-boundary channel.
What the React 18 migration changed
This architecture was designed in the React 17 era. Last week the project upgraded to React 18, and the overall structure of the bridge didn't change — three node types, data-anchor delegation, the queueMicrotask timing, the BridgeEvent communication, all preserved.
But three specific things had to change:
1. ReactDOM.render → createRoot + rootRegistry
In R17, calling ReactDOM.render(elements, dom) on the same DOM multiple times was fine.
In R18, createRoot(dom) cannot be called twice on the same DOM — you get a warning and, in some cases, errors. So we added the rootRegistry:
let root = rootRegistry.get(node)
if (!root) {
root = createRoot(dom)
rootRegistry.set(node, root)
}
root.render(elements)
This is a real R17 → R18 difference, not an optimization. The API contract changed.
2. Effect cleanup is now async, breaking the BridgeNode.getGraph injection
In R17, useEffect cleanup ran synchronously. We relied on it: at unmount, we'd immediately reset BridgeNode.getGraph to () => null.
In R18, cleanup can be deferred to a future microtask. The result: the component has unmounted, but a downstream component (inside some still-mounted React subtree) calls BridgeNode.getGraph() — and gets undefined. Crash.
The fix:
useEffect(() => {
// React 18 effect cleanup may run asynchronously
// prevent getGraph from briefly being undefined and crashing callers
const getGraph = () => graph
BridgeNode.getGraph = getGraph
return () => {
BridgeNode.getGraph = () => null
}
}, [graph])
This kind of "global static method injected by an Effect" pattern silently depends on synchronous cleanup. R18 broke that hidden assumption once.
3. createRoot enforces a stricter container type
R18's createRoot requires an HTML element as its mount target. If ${id}-react-root accidentally lives in an SVG namespace (e.g., colliding with an id on one of G6's internal <g> elements), R18 throws.
The fix is just to ensure the mount point is always an actual HTML <div> — handled via a naming convention (-react-root suffix) that avoids G6's id space.
Honest balance sheet
| What we got | What we paid |
|---|---|
| After the bridge, business iteration = pure React work | The bridge itself is non-trivial code that needs an owner |
| Three-tier node types — React cost paid only where needed | Adding a new node type requires understanding the bridge (rare, but the cost exists) |
| Antd, synthetic events, hooks all work normally inside nodes |
data-anchor is a string contract — no IDE / TS help |
| G6 knowledge stays in one file, doesn't spread | Debugging the bridge itself means thinking across G6 state + React tree + DOM attach timing |
| The core structure survived the R17 → R18 migration | But the migration exposed: global-static-method dependency on cleanup timing, SVG mount restriction, createRoot non-idempotence |
Performance isn't on this table — it was never the point.
If I were doing this today, what I'd keep and change
Would keep:
- The three-node-type layering. This is the most restrained part of the design. Don't flatten it for the sake of "uniformity."
- G6 wrapper + empty div + inner React mount. The two worlds are physically separated; events don't fight.
-
The
data-anchorstring contract. It's ugly, but it works. Trying to "upgrade" it to typed callbacks usually breaks the isolation. -
BridgeEventonwindow. Crude but stable. An order of magnitude simpler than trying to push state across multiple React roots via Context.
Would change:
-
The
BridgeNode.getGraphglobal-static-method pattern. R18's async cleanup exposed its fragility. A Context + a stable ref would be better, at the cost of more wiring on the consumer side. The convenience back then bought us a hidden debt. - Write the README before the code. The biggest risk to this architecture isn't technical — it's that no one else can fully read it. The code can be a black box; the docs cannot.
-
rootRegistry-style explicit lifecycle should be a first-class citizen, not a patch. R18 made this non-optional.
One last thing
Today the ecosystem around G6 + React (and graph-editor libraries with built-in React nodes) is crowded — React Flow, @infinit-canvas/react, X6's React adapter, and several others. This implementation is dated.
But going back to that branch just now, reading the code line by line, I still remembered what I was after back then — making G6 invisible to business code, making the node UI just another React component, keeping the two worlds clean of each other.
The codebase is in a private branch. Class names and code snippets have been generalized; the architecture and semantics match the actual implementation.
Top comments (0)