react flow from the top: packages, components, types
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
the repo is three packages, and only one of them knows about react.
@xyflow/systemis 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, theNodeChangeunion).
- the
@xyflow/reactis a binding. its only runtime dependencies are@xyflow/system,zustand, andclasscat(a class-name helper). it owns the store, the components, and the hooks.@xyflow/svelteis 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
reading top to bottom:
ReactFlowrenders adivand aWrapper. 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 whyuseReactFlow()works in a sibling component only when you add the provider yourself.ReactFlowProvidercallscreateStore()once. everything below reads from that zustand store.StoreUpdaterrenders nothing. it copies the props you passed to<ReactFlow>into the store whenever they change. (part 2 covers it.)GraphView→FlowRendererset up the layers.FlowRendererholds the keyboard shortcuts (delete, multi-select), then rendersZoomPane→Pane.ZoomPanecreates theXYPanZoominstance: wheel, pinch, and drag-to-pan all land here.Panehandles clicks on empty canvas and the drag-to-select rectangle.Viewportis onedivwith one csstransform. 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, oneEdgeWrapperper edge),ConnectionLine(only while you're dragging a new edge), andNodeRenderer(oneNodeWrapperper node). edges come first in the dom, so by default nodes paint over them. - each
NodeWrapperpositions its node, measures it, and runsXYDragthrough theuseDraghook. inside it sits your node component, and inside that, anyHandleyou render, each wired toXYHandle.
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
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:
three moves, and you'll see them again at every XY* call site:
- create once, in an effect, and keep the instance in a ref. react never re-renders because of it.
- 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. 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,updateConnectionLookupsystem/src/xy*/: one folder per interaction modulesystem/src/utils/edges/: bezier, smoothstep, and straight path mathreact/src/store/index.ts:createStore, all store actionsreact/src/components/StoreUpdater/: props → storereact/src/container/: the tree above, fromReactFlowtoNodeRendererreact/src/components/NodeWrapper/: per-node positioning, measuring, draggingreact/src/hooks/: the public hooks, plus the internal glue likeuseDrag
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,XYHandlefrom 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.