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:

useEffect(
  () => {
    for (const fieldName of fieldsToTrack) {
      const fieldValue = props[fieldName];
      const previousFieldValue = previousFields.current[fieldName];

      if (fieldValue === previousFieldValue) continue;
      if (typeof props[fieldName] === 'undefined') continue;
      // Custom handling with dedicated setters for some fields
      if (fieldName === 'nodes') setNodes(fieldValue as Node[]);
      else if (fieldName === 'edges') setEdges(fieldValue as Edge[]);
      else if (fieldName === 'minZoom') setMinZoom(fieldValue as number);
      else if (fieldName === 'maxZoom') setMaxZoom(fieldValue as number);
      else if (fieldName === 'translateExtent') setTranslateExtent(fieldValue as CoordinateExtent);
      else if (fieldName === 'nodeExtent') setNodeExtent(fieldValue as CoordinateExtent);
      else if (fieldName === 'ariaLabelConfig')
        store.setState({ ariaLabelConfig: mergeAriaLabelConfig(fieldValue as AriaLabelConfig) });
      // Renamed fields
      else if (fieldName === 'fitView') store.setState({ fitViewQueued: fieldValue as boolean });
      else if (fieldName === 'fitViewOptions') store.setState({ fitViewOptions: fieldValue as FitViewOptions });
      // General case
      else store.setState({ [fieldName]: fieldValue });
    }

    previousFields.current = props;
  },
  // Only re-run the effect if one of the fields we track changes
  fieldsToTrack.map((fieldName) => props[fieldName])
);

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:

nodeLookup.clear();
parentLookup.clear();

for (const userNode of nodes) {
  let internalNode = tmpLookup.get(userNode.id);

  if (_options.checkEquality && userNode === internalNode?.internals.userNode) {
    nodeLookup.set(userNode.id, internalNode);
  } else {
    const positionWithOrigin = getNodePositionWithOrigin(userNode, _options.nodeOrigin);
    const extent = isCoordinateExtent(userNode.extent) ? userNode.extent : _options.nodeExtent;
    const clampedPosition = clampPosition(positionWithOrigin, extent, getNodeDimensions(userNode));

    internalNode = {
      ..._options.defaults,
      ...userNode,
      measured: {
        width: userNode.measured?.width,
        height: userNode.measured?.height,
      },
      internals: {
        positionAbsolute: clampedPosition,
        // if user re-initializes the node or removes `measured` for whatever reason, we reset the handleBounds so that the node gets re-measured
        handleBounds: parseHandles(userNode, internalNode),
        z: calculateZ(userNode, selectedNodeZ, _options.zIndexMode),
        userNode,
      },
    };

    nodeLookup.set(userNode.id, internalNode);
  }

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:

export type InternalNodeBase<NodeType extends NodeBase = NodeBase> = Omit<NodeType, 'measured'> & {
  measured: {
    width?: number;
    height?: number;
  };
  internals: {
    positionAbsolute: XYPosition;
    z: number;
    rootParentIndex?: number;
    /**
     * Holds a reference to the original node object provided by the user.
     * Used as an optimization to avoid certain operations.
     */
    userNode: NodeType;
    handleBounds?: NodeHandleBounds;
    bounds?: NodeBounds;
  };
};
  • positionAbsolute is where the node really sits on the canvas. your position is relative to its parent, if it has one, and shifted by nodeOrigin. the renderer never wants the relative one.
  • z is the computed stacking order, including the bump a selected node gets.
  • handleBounds holds where each handle sits inside the node. it's empty until the node has been measured in the dom (part 3).
  • userNode points 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:

triggerNodeChanges: (changes) => {
  const { onNodesChange, setNodes, nodes, hasDefaultNodes, debug } = get();

  if (changes?.length) {
    if (hasDefaultNodes) {
      const updatedNodes = applyNodeChanges(changes, nodes);
      setNodes(updatedNodes);
    }

    if (debug) {
      console.log('React Flow: trigger node changes', changes);
    }

    onNodesChange?.(changes);
  }
},

this is the whole difference between controlled and uncontrolled react flow. it comes down to one boolean.

  • uncontrolled: you passed defaultNodes, so hasDefaultNodes is true. react flow applies the change to its own copy and calls setNodes itself.
  • controlled: you passed nodes. react flow applies nothing. it only calls your onNodesChange and waits. if you never feed a new array back through the nodes prop, 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:

for (const element of elements) {
  const changes = changesMap.get(element.id);

  /*
   * When there are no changes for an element we can just push it unmodified,
   * no need to copy it.
   */
  if (!changes) {
    updatedElements.push(element);
    continue;
  }

  // If we have a 'remove' change queued, it'll be the only change in the array
  if (changes[0].type === 'remove') {
    continue;
  }

  if (changes[0].type === 'replace') {
    updatedElements.push({ ...changes[0].item });
    continue;
  }

  /**
   * For other types of changes, we want to start with a shallow copy of the
   * object so React knows this element has changed. Sequential changes will
   * each _mutate_ this object, so there's only ever one copy.
   */
  const updatedElement = { ...element };

  for (const change of changes) {
    applyChange(change, updatedElement);
  }

  updatedElements.push(updatedElement);
}

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.

  • parentLookup maps a parent id to its children. adoptUserNodes fills 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.)
  • edgeLookup and connectionLookup are 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.
/*
 * We add the connection to the connectionLookup at the following keys
 * 1. nodeId, 2. nodeId-type, 3. nodeId-type-handleId
 * If the key already exists, we add the connection to the existing map
 */
let key = nodeId;
const nodeMap = connectionLookup.get(key) || new Map();
connectionLookup.set(key, nodeMap.set(connectionKey, connection));

key = `${nodeId}-${type}`;
const typeMap = connectionLookup.get(key) || new Map();
connectionLookup.set(key, typeMap.set(connectionKey, connection));

if (handleId) {
  key = `${nodeId}-${type}-${handleId}`;
  const handleMap = connectionLookup.get(key) || new Map();
  connectionLookup.set(key, handleMap.set(connectionKey, connection));
}

"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:

  • buildTagGraph makes 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 fitView prop and renders a FitOnLoad child that calls fitView() 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 time ready flips 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 same fitViewQueued flag 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.