react out of the hot path: how react flow renders
react flow draws every node as a real dom element: a react component you wrote. that sounds too slow for a canvas you pan, zoom, and drag at 60 frames a second. it isn't, because most frames never touch react. this post shows which parts re-render for which update, and why.
same pin as before: xyflow commit 3d35b57. the code excerpts are collected at the end.
the answer first
that grid comes from a small experiment. i built a controlled flow (your component holds the nodes with useNodesState) with three nodes a, b, c and two edges, and put a counter inside the custom node and edge components. then i made five updates and counted what rendered:
- pan or zoom: nothing at all.
- drag
a:a, the one edge touching it, and your component. - add a node: only the new node.
- change one node's
data: only that node. edges don't care aboutdata. - copy every node object: every node, even though nothing really changed. edges stay put, because their endpoints didn't move.
one caveat: the test ran in jsdom, where nothing has a real size, so i gave the nodes a size and handle positions up front. real browsers have to measure first. that's the second half of this post.
panning: one style change
every node and edge lives inside one div, the viewport. nodes are positioned in graph coordinates, not screen pixels. so panning and zooming only ever change one thing: the css transform on that div.
the viewport doesn't read the transform the usual react way. if it did, every pan frame would re-render it. instead it listens to the store directly and writes style.transform itself. the source comment says it plainly: keep react out of the hot path.
so a pan is three steps. d3 reports a zoom event, the store's transform updates, and one line sets a style string. no component function runs.
one small footnote: every store selector in the tree still runs on each pan. zustand calls it and compares the answer. what's skipped is the render, because the answer is the same. that's cheap, but it's why the selectors below are kept tiny.
dragging: ids, not nodes
the component that draws the node list asks the store for one thing: the list of node ids. dragging moves a node but doesn't change the list, so the list doesn't re-render.
each node then has its own wrapper, NodeWrapper, which asks the store for just its own node. the answer is compared by reference: same object, no render.
this is where part 2 pays off. during a drag, only the dragged node becomes a new object. every other node comes back as the same object as before, so every other wrapper skips its render. it's also why the "copy every node" row lights up: new objects look like change, even when the contents are identical.
edges work the same way. each edge looks up its two nodes, works out its two endpoints, and only re-renders if those numbers change. drag a and only a→b gets new numbers.
adding a node changes the id list, so the list re-renders. but each wrapper is memoized, and the existing ones get the same props, so only the new node actually renders.
that leaves "you". in a controlled flow, react flow sends the drag back to your onNodesChange, you call setNodes, and your component re-renders. that's what controlled means. react flow stops it spreading: everything under <ReactFlow> is memoized, so the render stops at the top. that only works if what you pass in stays the same between renders. it's why react flow warns you when you create a new nodeTypes object on every render.
where a node sits
a node's position is one more transform, inside the viewport's. the wrapper sets translate(x, y) using the node's final position on the canvas, after parents and origin are worked out.
a node that hasn't been measured yet is rendered with visibility: hidden. it's in the page and takes up space, but you can't see it. that's the first step of measuring.
measuring
react flow can't know how big your custom node is until the browser lays it out. it needs the size for fit view, selection, and parent bounds, and it needs the handle positions because that's where edges attach. so every new node goes through a measuring pass before it's shown:
there's one ResizeObserver for the whole graph, not one per node. the browser hands over every size change in one batch, so a hundred new nodes become one update to the store and one round of selectors.
measuring has a catch: everything inside the viewport is scaled by the zoom. at zoom 2, a handle 10 units from the node's corner is 20 pixels away on screen. so react flow reads the current zoom off the viewport div itself and divides by it. it asks the div, not the store, because the div's transform is what the browser used when it laid those pixels out.
handles are found by class name and read their id and side from data attributes. that's the real job of the <Handle> component: it stamps those on.
the measurement lands in a new node object. that node's wrapper sees a change, re-renders, and becomes visible. the store then runs a queued fit view, if there is one, and tells your onNodesChange about the new sizes. in controlled mode that's one more render of your component.
edges wait
an edge can't draw until both of its nodes know their size and where their handles are. until then it renders nothing. that's why, on a slow page, nodes can appear a moment before their edges.
there's a shortcut: if you give a node a width, a height, and a handles list yourself, it counts as ready without measuring. my experiment used that shortcut, and it's what server rendering relies on, since a server has nothing to measure.
only rendering what's visible
onlyRenderVisibleElements is off by default, and now you can see why it's a tradeoff. turned on, the id list depends on the viewport, so every pan frame checks every node to see which ones are on screen. nodes that scroll out of view unmount. for thousands of heavy nodes that's a win. for a few hundred light ones, it adds work to every pan frame to save renders that weren't happening anyway.
in this blog
the grid says a controlled flow re-renders "you" on every drag frame. on /blog, "you" is BlogGraph, and it renders more than the graph: it also renders the sidebar. the sidebar isn't memoized and gets a freshly filtered list of posts on every render. so dragging a tag node re-filters every post and re-renders the sidebar list, every frame.
at this blog's size you'd never feel it. but it's the "you" column in real life. the fix is to move things around, not to add useMemo everywhere: give the graph its own component that owns the nodes, so a drag re-renders only that.
next: the interaction layer itself. XYPanZoom, XYDrag, and XYHandle from the inside.
reading the source
the viewport listens to the store and writes the transform itself:
the node list subscribes to ids only:
each node's wrapper subscribes to its own node, compared by reference:
where the node is drawn, and hidden until measured:
one shared observer turns a batch of resizes into one store update:
the zoom is read from the viewport div:
handle offsets are divided by that zoom:
an edge's check that both nodes are ready: