DEV Community

kiliaosi
kiliaosi

Posted on

A Few-Years-Old G6 + React Bridge That Just Survived a React 18 Migration

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 — import Antd, 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, calling group.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'
Enter fullscreen mode Exit fullscreen mode
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)
}
Enter fullscreen mode Exit fullscreen mode

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>`
  }
})
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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)
  })
}
Enter fullscreen mode Exit fullscreen mode

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:

Mount sequence: G6 afterDraw delegates to the BridgeNode, which waits one microtask, then either creates or reuses a React root and renders your component

One more thing worth noting about the update lifecycle: when a node's data changes, G6's update hook calls _destroy then _drawthe 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 neededonClick, 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.rendercreateRoot + 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)
Enter fullscreen mode Exit fullscreen mode

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])
Enter fullscreen mode Exit fullscreen mode

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:

  1. The three-node-type layering. This is the most restrained part of the design. Don't flatten it for the sake of "uniformity."
  2. G6 wrapper + empty div + inner React mount. The two worlds are physically separated; events don't fight.
  3. The data-anchor string contract. It's ugly, but it works. Trying to "upgrade" it to typed callbacks usually breaks the isolation.
  4. BridgeEvent on window. Crude but stable. An order of magnitude simpler than trying to push state across multiple React roots via Context.

Would change:

  1. The BridgeNode.getGraph global-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.
  2. 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.
  3. 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)