your nodes aren't the nodes react flow renders
you hand <ReactFlow> an array of nodes. it looks like react flow renders that array. it doesn't. it copies every node into a Map of its own, adds a block of fields you never wrote, and renders from the copy. your array is the input; the map is the truth the renderer reads.
part 1 drew the map. this post follows one node from your array into that map and back out again. same pin as before: commit 3d35b57 (@xyflow/react 12.12.0). this blog runs 12.10.2; i checked the installed build, and the code quoted here is the same in both.
getting the array in
props don't reach the store by magic. a component called StoreUpdater renders null and exists only to copy props into the store when they change:
fifty-eight props go through this loop. most are copied straight across. a handful get a setter because they need work done on the way in, and nodes is the one that matters here. the comparison is ===. a new array reference is the only thing that counts as "the nodes changed", which is the first hint that react flow cares a lot about object identity.
one other line to remember for later: the fitView prop isn't an action. it's renamed to fitViewQueued, a flag that waits for something to happen.
adoption
setNodes passes your array to adoptUserNodes in the system package, with checkEquality: true. the function takes your plain nodes and builds internal ones:
tmpLookup is a copy of the old map, taken just before the clear. the lookup is rebuilt from scratch on every call, but an old internal node can carry over.
the new node is your node spread flat, plus an internals object. the type spells out what's in it:
positionAbsoluteis where the node really sits on the canvas. yourpositionis relative to its parent, if it has one, and shifted bynodeOrigin. the renderer never wants the relative one.zis the computed stacking order, including the bump a selected node gets.handleBoundsholds where each handle sits inside the node. it's empty until the node has been measured in the dom (part 3).userNodepoints back at the exact object you passed in.
the identity check
that last field is the one "used as an optimization". look at the if again:
if (_options.checkEquality && userNode === internalNode?.internals.userNode) {
nodeLookup.set(userNode.id, internalNode);
}
if the object in your new array is the same object react flow adopted last time, it skips everything. no position math, no clamping, no handle parsing. it just moves the old internal node into the new map.
react flow never deep-compares nodes. it checks === and treats that as a promise: if you didn't make a new object, nothing changed. mutate a node in place and react flow won't notice. make a new object for every node on every update and react flow redoes the work for all of them.
this matters most while you drag. dragging a node fires a position change every frame. it would be expensive if every frame rebuilt every node. it doesn't, because of how the changes get applied.
changes come back out
when you drag, react flow never writes the new position into its map. it describes the move as a change object and hands it to triggerNodeChanges:
this is the whole difference between controlled and uncontrolled react flow. it comes down to one boolean.
- uncontrolled: you passed
defaultNodes, sohasDefaultNodesis true. react flow applies the change to its own copy and callssetNodesitself. - controlled: you passed
nodes. react flow applies nothing. it only calls youronNodesChangeand waits. if you never feed a new array back through thenodesprop, the node doesn't move.
a change is a small tagged object: position, dimensions, select, remove, add, or replace. in both modes something has to turn those into a new array, and usually that's applyNodeChanges:
nodes with no change are pushed through as the same object. only the nodes that changed get a shallow copy. now the two halves fit:
drag node B one frame
-> XYDrag reports B's new position
-> triggerNodeChanges([{ id: 'B', type: 'position', ... }])
-> onNodesChange -> applyNodeChanges
A: same object B: new object C: same object
-> new nodes array -> StoreUpdater -> setNodes
-> adoptUserNodes
A: === hit, reuse B: miss, rebuild C: === hit, reuse
in a graph of five hundred nodes, one node per frame is rebuilt. the other 499 are a lookup and a Map.set. neither function knows about the other. applyNodeChanges keeps objects that didn't change, and adoptUserNodes trusts them. the speed comes from the two sticking to the same rule.
it also shows why onNodesChange is a list of changes and not a new array. react flow can't produce your next array, because in controlled mode it doesn't own it. it can only describe what happened and trust you to apply it the same way.
the other maps
nodes aren't the only thing copied into a lookup.
parentLookupmaps a parent id to its children.adoptUserNodesfills it in the same pass, which is why a parent has to come before its children in your array: when the child is adopted, the parent must already be in the map. (part 5 covers subflows.)edgeLookupandconnectionLookupare rebuilt whenever edges change. the edge map is plain id-to-edge. the connection map is more interesting: each edge is filed under three keys per end.
"every edge touching node B", "every edge leaving B", and "every edge on B's out handle" are three questions react flow's api answers all the time: useNodeConnections, useHandleConnections, and getNodeConnections on the instance all read straight from this map. storing each edge three times turns each question into a single get. the price is a full rebuild whenever the edges array changes, which happens far less often than the lookups do.
one flag that waits
back to fitViewQueued. a node you've never rendered has no size. measured.width is undefined until the node is in the dom and a ResizeObserver has reported back. while any node that isn't hidden is unmeasured, adoptUserNodes returns nodesInitialized: false.
fitting the view needs sizes, so the fitView prop only sets the flag. the store resolves it later, in setNodes or after a measurement pass, once every node has a size. the measurement pipeline is the subject of part 3.
in this blog
the graphs on / and /blog are both controlled. BlogGraph.tsx uses useNodesState, which is useState plus a callback that runs applyNodeChanges: the exact loop above. dragging a tag node on /blog rebuilds exactly one internal node per frame.
two smaller things that make more sense now:
buildTagGraphmakes fresh node objects every time you pick a tag, so every node misses the identity check and is adopted from scratch. for a few dozen tag nodes that costs nothing. if the graph grew to thousands, the fix would be to reuse the objects for nodes that stay.- the component passes the
fitViewprop and renders aFitOnLoadchild that callsfitView()after a 100ms timeout. these do different jobs. the prop is copied into the store only when its value changes, so it queues exactly one fit, on mount. the timer runs each timereadyflips back to true after you pick a tag. without it, filtering would leave the viewport wherever the last layout put it. but the 100ms wait isn't needed:useReactFlow().fitView()doesn't fit right away either. it sets the samefitViewQueuedflag and waits for measurement. the timer only delays a call that already knows how to wait. that's a small refactor for another day.
next: how that map reaches the screen, and why panning never re-renders a single react component.