react flow from the top: packages, components, types

part 1 of 3 in react flow internals

react flow is one of those libraries you can use for a year without ever wondering how it's put together. <ReactFlow nodes edges />, a custom node or two, done. this series opens it up. before any deep dive, here's the map: three diagrams and the one pattern that ties them together.

everything is from the xyflow monorepo at commit 3d35b57 (@xyflow/react 12.12.0).

three packages

@xyflow/systemno framework: plain ts + domyour app@xyflow/reactcomponents, hooks, zustand store@xyflow/sveltecomponents, svelte storesXYPanZoompan and zoomXYDragdrag nodesXYHandleconnectionsXYResizerresize nodesXYMinimapminimaputils/storeadoptUserNodesutils/edgesbezier, smoothsteputils/graphbounds, fitViewtypesNodeBase, changesd3zoom, drag, selection, interpolate
arrows point at what a package depends on. your app picks one binding.

the repo is three packages, and only one of them knows about react.

  • @xyflow/system is the engine. it has no framework dependency at all, just typescript, the dom, and four small d3 modules (d3-zoom, d3-drag, d3-selection, d3-interpolate). it contains two kinds of code:
    • the XY* modules: XYPanZoom, XYDrag, XYHandle, XYResizer, XYMinimap. each one attaches behavior to a dom element: listen to pointer events, do the math, report back through callbacks.
    • the utils: pure functions. adopting user nodes into lookups, bezier and smoothstep edge paths, bounds and fit-view math, and the shared types (NodeBase, EdgeBase, the NodeChange union).
  • @xyflow/react is a binding. its only runtime dependencies are @xyflow/system, zustand, and classcat (a class-name helper). it owns the store, the components, and the hooks.
  • @xyflow/svelte is the same binding written for svelte, over the same system package.

the svelte package is the useful one for understanding react flow. anything both bindings need is in system. anything left in @xyflow/react is there because it's about react: rendering, subscriptions, hooks.

the component tree

ReactFlowReactFlowProvidercreateStore()StoreUpdaterGraphViewFlowRendererZoomPaneXYPanZoomPaneViewportEdgeRendererConnectionLineNodeRendererNodeWrapperXYDrag (via useDrag)your node componentHandleXYHandleSelectionListenercopies props into the storekeyboard shortcutsclicks, selection boxone css transform for allsvg, EdgeWrapper per edgeonly while connectingsubscribes to node ids onlyplus Attribution, a11y text@xyflow/react@xyflow/systemyour code
indentation is nesting. right column: where the store is created and where each system module attaches.

reading top to bottom:

  • ReactFlow renders a div and a Wrapper. the wrapper checks whether a store already exists in context. if you wrapped the flow in your own <ReactFlowProvider>, it reuses that one; otherwise it creates the provider itself. that's why useReactFlow() works in a sibling component only when you add the provider yourself.
  • ReactFlowProvider calls createStore() once. everything below reads from that zustand store.
  • StoreUpdater renders nothing. it copies the props you passed to <ReactFlow> into the store whenever they change. (part 2 covers it.)
  • GraphView → FlowRenderer set up the layers. FlowRenderer holds the keyboard shortcuts (delete, multi-select), then renders ZoomPane → Pane.
  • ZoomPane creates the XYPanZoom instance: wheel, pinch, and drag-to-pan all land here. Pane handles clicks on empty canvas and the drag-to-select rectangle.
  • Viewport is one div with one css transform. every node and edge lives inside it, so panning the whole graph moves exactly one element. (part 3.)
  • inside the viewport, in dom order: EdgeRenderer (an svg, one EdgeWrapper per edge), ConnectionLine (only while you're dragging a new edge), and NodeRenderer (one NodeWrapper per node). edges come first in the dom, so by default nodes paint over them.
  • each NodeWrapper positions its node, measures it, and runs XYDrag through the useDrag hook. inside it sits your node component, and inside that, any Handle you render, each wired to XYHandle.

the interaction modules attach at three very different depths. pan/zoom attaches once, near the root. drag attaches once per node. connecting attaches once per handle. none of them is a component: they're objects the components create and hold.

the core types

ReactFlowStatezustand storenodes, edgesnodeLookup: Map<id, InternalNode>parentLookupedgeLookup, connectionLookuptransform, width, heightpanZoomconnection, userSelectionRectonNodesChange, hasDefaultNodesfitViewQueued, nodesInitialized+ config flags and callbackssetNodes(), setEdges()triggerNodeChanges()updateNodeInternals()updateNodePositions()panBy(), setCenter(), reset()InternalNode...every field of your nodemeasured: width, heightinternals.positionAbsoluteinternals.zinternals.handleBoundsinternals.userNodeNodewhat you pass inid, typepositiondataparentId, hidden, selected ...PanZoomInstanceupdate()setViewport()scaleTo(), scaleBy()syncViewport()destroy()XYDragInstanceupdate()destroy()one per NodeWrapper,kept in a refXYHandleonPointerDown()isValid()one shared object,no instance stateuserNodegetStoreItems()@xyflow/react@xyflow/systemyour code
solid arrows: holds a reference. dashed arrow: reads the store through a callback.

three families of types matter.

the store (ReactFlowState) is a flat zustand object. at the top are the fields you'd expect: nodes, edges, the viewport transform. then come the lookups: nodeLookup maps an id to an internal node, parentLookup maps a parent id to its children, and edgeLookup and connectionLookup index the edges. below those sit dozens of flags and callbacks copied from props, and at the bottom the actions: setNodes, triggerNodeChanges, updateNodeInternals, updateNodePositions.

the nodes come in two versions. Node is what you write: id, position, data, maybe a parentId. InternalNode is react flow's copy. it has all of your fields, plus a measured size and an internals block holding the absolute position, stacking order, handle bounds, and a pointer back to your original object. the renderer only ever reads the internal version. part 2 is about how one becomes the other, and why that back-pointer makes dragging cheap.

the interaction instances share an api. PanZoomInstance, XYDragInstance, XYResizerInstance, and XYMinimapInstance all expose update() and destroy(). XYHandle is the odd one out: a single shared object with onPointerDown() and isValid(). it keeps no state between calls; each connection drag lives in the closure of its own onPointerDown call.

one arrow in that diagram is the key to the whole codebase: the dashed one labeled getStoreItems().

the one pattern

the system package never imports the store. it can't; the store is a react (or svelte) thing. so every XY* module takes functions that read the store when it needs them. here's the react side of that deal for dragging:

const store = useStoreApi();
const [dragging, setDragging] = useState<boolean>(false);
const xyDrag = useRef<XYDragInstance>();

useEffect(() => {
  if (disabled) {
    return;
  }

  xyDrag.current = XYDrag({
    getStoreItems: () => store.getState(),
    onNodeMouseDown: (id: string) => {
      handleNodeClick({
        id,
        store,
        nodeRef,
      });
    },
    onDragStart: () => {
      setDragging(true);
    },
    onDragStop: () => {
      setDragging(false);
    },
  });

  return () => {
    xyDrag.current?.destroy();
    xyDrag.current = undefined;
  };
}, [disabled, store, nodeRef]);

useEffect(() => {
  if (disabled || !nodeRef.current || !xyDrag.current) {
    return;
  }

  xyDrag.current.update({
    noDragClassName,
    handleSelector,
    domNode: nodeRef.current,
    isSelectable,
    nodeId,
    nodeClickDistance,
  });
}, [noDragClassName, handleSelector, disabled, isSelectable, nodeRef, nodeId, nodeClickDistance]);

three moves, and you'll see them again at every XY* call site:

  1. create once, in an effect, and keep the instance in a ref. react never re-renders because of it.
  2. pass getters, not values. getStoreItems: () => store.getState() means the drag code always reads the store as it is now, mid-drag, without subscribing to it.
  3. update() when props change, destroy() on unmount. the lifecycle of the dom behavior follows the component, but the behavior itself isn't react code.

the result also answers "how does the svelte version exist at all": swap the effect for a svelte action and store.getState() for a svelte store read. XYDrag never notices.

where things live

a short file map for the rest of the series. all paths are under packages/.

  • system/src/utils/store.ts: adoptUserNodes, updateAbsolutePositions, updateNodeInternals, updateConnectionLookup
  • system/src/xy*/: one folder per interaction module
  • system/src/utils/edges/: bezier, smoothstep, and straight path math
  • react/src/store/index.ts: createStore, all store actions
  • react/src/components/StoreUpdater/: props → store
  • react/src/container/: the tree above, from ReactFlow to NodeRenderer
  • react/src/components/NodeWrapper/: per-node positioning, measuring, dragging
  • react/src/hooks/: the public hooks, plus the internal glue like useDrag

the rest of the series

  • part 2, data model: how your nodes become internal nodes, controlled vs uncontrolled, and the change events that come back out.
  • part 3, rendering: the single transform, why the node list only re-renders when ids change, and how nodes get measured.
  • part 4, interaction: XYPanZoom, XYDrag, XYHandle from the inside.
  • part 5, edges and subflows: path math, parent nodes, and a comparison with cytoscape's canvas renderer.

in this blog

the graphs on / and /blog use a small slice of this map. BlogGraph.tsx renders <ReactFlow> with custom nodeTypes and edgeTypes from TagGraphParts.tsx, controlled through useNodesState, and calls fitView from useReactFlow(). that call doesn't move the camera directly: it queues a fit in the store, which runs through the panZoom instance once every node has a size. each tag node renders two Handles, but at opacity: 0, only so the pencil edges have somewhere to attach. no minimap, no resizer. the pieces doing real work here are the store, the viewport transform, XYPanZoom, and one XYDrag per tag node.